test: just a bunch of tests (#709)

This commit is contained in:
Paul Makles
2026-09-24 17:31:38 +01:00
committed by GitHub
parent 6297cf30a3
commit da0a95b439
26 changed files with 1357 additions and 67 deletions
+2 -1
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
#MISE description="Run all end-to-end tests"
#MISE depends=["test:e2e:wait"]
#MISE depends=["test:e2e:wait", "//packages/restic-proxy:build"]
set -e
source "$(dirname "$0")/../../michael/env"
source "$(dirname "$0")/../../yucca-api/env"
export PATH="$(dirname "$0")/../../../../dist:$PATH"
pnpm --filter e2e test "$@"
+7
View File
@@ -51,6 +51,10 @@ if [ -z "${YUCCA_E2E_PREBUILT:-}" ]; then
mise run yucca-sdk:orchestration-ui:build >/dev/null
fi
echo "==> build restic-proxy (spawned by orchestration-api and the proxy suites)"
mise run //packages/restic-proxy:build >/dev/null
export PATH="$ROOT/dist:$PATH"
echo "==> port-forward k3d services to the e2e host ports"
kubectl port-forward -n yucca svc/yucca-michael 3010:3010 >/tmp/yucca-e2e-pf.log 2>&1 & PF_PIDS+=($!)
kubectl port-forward -n yucca svc/yucca-mock-oidc 8092:8092 >>/tmp/yucca-e2e-pf.log 2>&1 & PF_PIDS+=($!)
@@ -123,6 +127,9 @@ source .mise/tasks/yucca-api/env
NODE_OPTIONS="--experimental-vm-modules --require $HERE/hostmap.cjs" \
pnpm --filter e2e exec jest --maxWorkers=3
grep -q "Minted a new token" /tmp/yucca-e2e-orch.log || {
echo "orchestration-api never routed restic through restic-proxy" >&2; exit 1; }
echo "==> web e2e (playwright against the k3d web)"
# Config lives in packages/web so @playwright/test resolves from web's deps.
pnpm --filter web exec playwright test --config="$ROOT/packages/web/playwright.k3d.config.ts"
+10 -4
View File
@@ -73,13 +73,19 @@ async function createForgottenSnapshot(workingDir: string, pruneRepoUrl: string)
// One spec file per mode rather than a `describe.each` here: jest parallelises
// across files, never within one, so as a single suite the WORM pass could only
// start once the plain pass had finished.
export function describeResticApi(name: string, writeOnce: boolean) {
const { repoUrl } = generateCase(writeOnce);
export function describeResticApi(
name: string,
writeOnce: boolean,
createRepoUrl = (worm: boolean) => Promise.resolve(generateCase(worm).repoUrl),
) {
let repoUrl: string;
describe(`${name} (e2e)`, () => {
let workingDir: string;
beforeAll(async () => {
repoUrl = await createRepoUrl(writeOnce);
// Private to this suite: the two modes run as separate jest workers, and
// a shared tmpdir has them overwriting each other's fixtures mid-backup.
workingDir = await mkdtemp(join(tmpdir(), 'restic-e2e-'));
@@ -152,7 +158,7 @@ export function describeResticApi(name: string, writeOnce: boolean) {
let otherRepoUrl: string;
beforeEach(async () => {
otherRepoUrl = generateCase(writeOnce).repoUrl;
otherRepoUrl = await createRepoUrl(writeOnce);
await init().repository(otherRepoUrl).password(password).run();
}, 10_000);
@@ -414,7 +420,7 @@ export function describeResticApi(name: string, writeOnce: boolean) {
describe('prune', () => {
async function createPruneRepo() {
const { repoUrl: pruneRepoUrl } = generateCase(writeOnce);
const pruneRepoUrl = await createRepoUrl(writeOnce);
await init().repository(pruneRepoUrl).password(password).run();
return pruneRepoUrl;
}
+61
View File
@@ -0,0 +1,61 @@
import { createRepository } from '@futo-org/backups-api-client';
import { ChildProcess, spawn } from 'node:child_process';
import { once } from 'node:events';
import { createInterface } from 'node:readline';
import { Readable } from 'node:stream';
import { describeResticApi } from 'src/restic-api.suite';
import { loginWithIdp, yuccaBaseUrl } from 'src/yucca-auth';
const READY_FD = 3;
export type ResticProxy = { child: ChildProcess; address: string };
export async function startResticProxy(throttle: Record<string, string> = {}): Promise<ResticProxy> {
const child = spawn('restic-proxy', {
env: {
...process.env,
RESTIC_PROXY_API_URL: `${yuccaBaseUrl}/api`,
RESTIC_PROXY_PORT: '0',
RESTIC_PROXY_READY_FD: String(READY_FD),
...throttle,
},
stdio: ['ignore', 'inherit', 'inherit', 'pipe'],
});
const [line] = await once(createInterface({ input: child.stdio[READY_FD] as Readable }), 'line');
const { address } = JSON.parse(line as string) as { address: string };
return { child, address };
}
export async function createProxiedRepository(
proxy: ResticProxy,
accessToken: string | undefined,
name: string,
worm: boolean,
) {
const { repository } = await createRepository(
{ name, worm },
{ baseUrl: yuccaBaseUrl, headers: { Cookie: `yucca-access-token=${accessToken}` } },
);
return `rest:http://${repository.id}:${accessToken}@${proxy.address}`;
}
export function describeResticProxy(name: string, writeOnce: boolean, sub: string) {
let proxy: ResticProxy;
let accessToken: string | undefined;
beforeAll(async () => {
accessToken = await loginWithIdp(sub);
proxy = await startResticProxy();
});
afterAll(() => {
proxy?.child.kill();
});
describeResticApi(name, writeOnce, (worm) =>
createProxiedRepository(proxy, accessToken, `${sub}-${worm ? 'worm' : 'plain'}`, worm),
);
}
+27
View File
@@ -0,0 +1,27 @@
import { parse } from 'cookie';
import { env } from 'src/env';
export const yuccaBaseUrl = `http://localhost:${env.YUCCA_API_PORT}`;
export const loginWithIdp = async (sub: string): Promise<string | undefined> => {
const { headers: loginHeaders } = await fetch(`${yuccaBaseUrl}/api/auth/oidc/login`, {
redirect: 'manual',
});
const redirectUrl = new URL(loginHeaders.get('Location')!);
redirectUrl.pathname = '/api/form';
redirectUrl.searchParams.set('sub', sub);
const { headers: oidcHeaders } = await fetch(redirectUrl, {
redirect: 'manual',
});
const { headers: callbackHeaders } = await fetch(oidcHeaders.get('Location'), {
redirect: 'manual',
headers: {
Cookie: loginHeaders.getSetCookie().join('; '),
},
});
return parse(callbackHeaders.getSetCookie().join('; '))['yucca-access-token'];
};
+212 -8
View File
@@ -1,6 +1,7 @@
import * as sdk from '@futo-org/backups-orchestrator-ui/sdk';
import { createEventSource } from 'eventsource-client';
import { randomBytes } from 'node:crypto';
import { parse } from 'cookie';
import { createEventSource, type EventSourceClient } from 'eventsource-client';
import { randomBytes, randomUUID } from 'node:crypto';
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { dirname, join, resolve, sep } from 'node:path';
@@ -11,8 +12,8 @@ const baseUrl = `http://localhost:22676`;
sdk.defaults.baseUrl = baseUrl;
let socket: Socket;
const startDeviceFlow = async () => {
const events = createEventSource(`${baseUrl}/api/yucca/auth/oidc/device`);
const startDeviceFlow = async (path = 'oidc/device') => {
const events = createEventSource(`${baseUrl}/api/yucca/auth/${path}`);
for await (const { data } of events) {
const message = JSON.parse(data);
@@ -24,24 +25,48 @@ const startDeviceFlow = async () => {
throw new Error('Device flow ended before it started');
};
const login = async () => {
const backendCreated = waitForMessage('BackendCreate');
const nextDeviceFlowEvent = async (events: EventSourceClient) => {
for await (const { data } of events) {
return JSON.parse(data);
}
const { events, userCode, verificationUri } = await startDeviceFlow();
throw new Error('Device flow ended without an event');
};
const approveDeviceCode = async (userCode: string, verificationUri: string, sub: string) => {
const approveUrl = new URL('/api/form/device', verificationUri);
approveUrl.searchParams.set('user_code', userCode);
approveUrl.searchParams.set('sub', 'bar');
approveUrl.searchParams.set('sub', sub);
const response = await fetch(approveUrl);
if (!response.ok) {
throw new Error(`Failed to approve device code: ${response.status} ${await response.text()}`);
}
};
const login = async () => {
const backendCreated = waitForMessage('BackendCreate');
const { events, userCode, verificationUri } = await startDeviceFlow();
await approveDeviceCode(userCode, verificationUri, 'bar');
await backendCreated;
events.close();
};
const completeSessionDeviceFlow = async (sub: string) => {
const { events, userCode, verificationUri } = await startDeviceFlow('session/device');
const result = nextDeviceFlowEvent(events);
await approveDeviceCode(userCode, verificationUri, sub);
try {
return await result;
} finally {
events.close();
}
};
beforeAll(async () => {
await sdk.resetOrchestrator();
@@ -96,9 +121,38 @@ describe('Auth', () => {
expect(verificationUri).toEqual(expect.any(String));
});
it('fails the session device flow before a backend is connected', async () => {
const events = createEventSource(`${baseUrl}/api/yucca/auth/session/device`);
await expect(nextDeviceFlowEvent(events)).resolves.toEqual({ type: 'FAILURE', reason: 'NOT_CONNECTED' });
events.close();
});
it('should log us in using IdP', async () => {
await login();
}, 30_000);
it('confirms the connected account through the session device flow', async () => {
await expect(completeSessionDeviceFlow('bar')).resolves.toEqual({ type: 'SUCCESS' });
}, 30_000);
it('rejects a different account in the session device flow', async () => {
await expect(completeSessionDeviceFlow('orchestration-api-intruder')).resolves.toEqual({
type: 'FAILURE',
reason: 'WRONG_ACCOUNT',
});
}, 30_000);
it('rejects an invalid session token', async () => {
await expect(sdk.createSession({ token: 'not-a-session-token' })).rejects.toMatchObject({ status: 401 });
});
it('returns 404 when creating a ticket for an unknown repository', async () => {
await expect(sdk.createTicket({ action: 'repository.delete', repositoryId: randomUUID() })).rejects.toMatchObject({
status: 404,
});
});
});
describe('Backend', () => {
@@ -211,6 +265,19 @@ describe('Onboarding', () => {
hasSkippedExtraConfig: true,
});
});
it('reports a bootstrap error to VictoriaLogs', async () => {
const reportedAt = Date.now();
await expect(sdk.reportError()).resolves.toEqual('');
const record = await waitForLog(
(entry: Record<string, unknown>) =>
JSON.stringify(entry).includes('Bootstrap error') && Date.parse(String(entry._time)) >= reportedAt,
);
expect(JSON.stringify(record)).toContain('Bootstrap error');
}, 60_000);
});
describe('Repository', () => {
@@ -252,6 +319,63 @@ describe('Repository', () => {
});
});
it('reconfigures the primary backend of a repository', async () => {
const { repository: moved } = await sdk.createRepository({ name: 'Moved Repository', worm: false });
const { backend } = await sdk.createLocalBackend({ path: await mkdtemp(join(tmpdir(), 'reconfigure-')) });
const primary = { id: backend.id, online: true, type: 'local' };
const event = waitForMessage('RepositoryUpdate');
await expect(sdk.reconfigureRepositoryPrimaryBackend(moved.id, { backendId: backend.id })).resolves.toEqual({
repository: expect.objectContaining({
id: moved.id,
name: 'Moved Repository',
backends: { primary, secondary: [] },
}),
});
await expect(event).resolves.toEqual({
type: 'RepositoryUpdate',
repositoryId: moved.id,
repository: expect.objectContaining({ backends: { primary, secondary: [] } }),
});
await expect(sdk.getRepositories()).resolves.toEqual({
repositories: expect.arrayContaining([
expect.objectContaining({
id: moved.id,
backends: expect.objectContaining({ primary: expect.objectContaining({ id: backend.id }) }),
}),
]),
});
});
it('returns 404 when reconfiguring onto an unknown backend', async () => {
await expect(
sdk.reconfigureRepositoryPrimaryBackend(repository.id, { backendId: randomUUID() }),
).rejects.toMatchObject({ status: 404 });
});
it('returns 404 when reconfiguring an unknown repository', async () => {
const { backend } = await sdk.createLocalBackend({ path: await mkdtemp(join(tmpdir(), 'reconfigure-')) });
await expect(
sdk.reconfigureRepositoryPrimaryBackend(randomUUID(), { backendId: backend.id }),
).rejects.toMatchObject({ status: 404 });
});
it('creates a ticket that redirects to the identity provider', async () => {
const { redirectTo } = await sdk.createTicket({ action: 'repository.delete', repositoryId: repository.id });
expect(Object.fromEntries(new URL(redirectTo).searchParams)).toEqual(
expect.objectContaining({
state: expect.any(String),
prompt: 'login',
max_age: '0',
}),
);
});
it('creates a repository', async () => {
const event = waitForMessage('RepositoryCreate');
@@ -374,6 +498,30 @@ describe('Repository', () => {
});
});
it('downloads a run log', async () => {
const {
runs: [{ id }],
} = await sdk.getRunHistory(repository.id);
const response = await fetch(`${baseUrl}/api/yucca/logs/${id}/download`);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toMatch(/^application\/jsonl/);
expect(response.headers.get('content-disposition')).toBe(`attachment; filename="${id}.jsonl"`);
const body = await response.text();
const lines = body
.split('\n')
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line));
expect(lines).toEqual(expect.arrayContaining([expect.objectContaining({ message_type: 'summary' })]));
});
it('returns 404 when downloading the log of an unknown run', async () => {
await expect(sdk.downloadRunLog('does-not-exist')).rejects.toMatchObject({ status: 404 });
});
it('list snapshots', async () => {
await expect(sdk.getSnapshots(repository.id)).resolves.toEqual({
snapshots: expect.arrayContaining([
@@ -891,6 +1039,62 @@ describe('Immich integration', () => {
}),
);
});
it('reports the immich backup status', async () => {
const { immichIntegration } = await sdk.getIntegrations();
await expect(sdk.getImmichBackupStatus()).resolves.toEqual(
expect.objectContaining({
integration: immichIntegration,
repository: expect.objectContaining({ id: immichIntegration!.id }),
backend: expect.objectContaining({ type: 'yucca' }),
schedule: expect.objectContaining({ id: immichIntegration!.scheduleId }),
databaseDump: { enabled: expect.any(Boolean), keepLastAmount: expect.any(Number) },
databaseDumpWarningIgnored: false,
}),
);
});
it('configures the immich database dump', async () => {
await sdk.configureImmichDatabaseDump({ enabled: false, keepLastAmount: 3 });
await expect(sdk.getImmichBackupStatus()).resolves.toEqual(
expect.objectContaining({ databaseDump: { enabled: false, keepLastAmount: 3 } }),
);
});
it('rejects a database dump retention below one', async () => {
await expect(sdk.configureImmichDatabaseDump({ keepLastAmount: 0 })).rejects.toMatchObject({ status: 400 });
});
it('ignores the database dump warning', async () => {
await sdk.ignoreImmichDatabaseDumpWarning();
await expect(sdk.getImmichBackupStatus()).resolves.toEqual(
expect.objectContaining({ databaseDumpWarningIgnored: true }),
);
});
it('starts an immich rollback and sets the maintenance token cookie', async () => {
const { immichIntegration } = await sdk.getIntegrations();
const response = await fetch(`${baseUrl}/api/yucca/integrations/immich/rollback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
repositoryId: immichIntegration!.id,
snapshotId: 'rollback-snapshot',
backupFileName: 'rollback.sql',
}),
});
expect(response.status).toBe(201);
expect(response.headers.getSetCookie().map((header) => parse(header))).toEqual([
expect.objectContaining({
immich_maintenance_token: `${immichIntegration!.id}:rollback-snapshot:rollback.sql`,
}),
]);
});
});
describe('Local backend', () => {
@@ -0,0 +1,58 @@
import { backup, init } from '@futo-org/restic-wrapper';
import { randomBytes } from 'node:crypto';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createProxiedRepository, startResticProxy } from 'src/restic-proxy.suite';
import { loginWithIdp } from 'src/yucca-auth';
const password = 'password';
const bytesPerSec = 128 * 1024;
const fileSize = 1024 * 1024;
const burst = 32 * 1024;
const throttledFloorMs = ((fileSize - burst) / bytesPerSec) * 1000;
describe('restic proxy throttle (e2e)', () => {
let accessToken: string | undefined;
let file: string;
beforeAll(async () => {
accessToken = await loginWithIdp('restic-proxy-throttle-e2e');
const workingDir = await mkdtemp(join(tmpdir(), 'restic-throttle-e2e-'));
file = join(workingDir, 'incompressible');
await writeFile(file, randomBytes(fileSize));
});
async function timeBackup(name: string, throttle: Record<string, string>) {
const proxy = await startResticProxy(throttle);
try {
const repoUrl = await createProxiedRepository(proxy, accessToken, name, false);
await init().repository(repoUrl).password(password).run();
const startedAt = performance.now();
await backup().repository(repoUrl).password(password).addFile(file).run();
return performance.now() - startedAt;
} finally {
proxy.child.kill();
}
}
it('paces uploads to the configured rate', async () => {
const elapsedMs = await timeBackup('throttled', {
RESTIC_PROXY_THROTTLE_BYTES_PER_SEC: String(bytesPerSec),
});
expect(elapsedMs).toBeGreaterThanOrEqual(throttledFloorMs);
}, 30_000);
it('uploads at full speed inside the quiet hours', async () => {
const elapsedMs = await timeBackup('quiet-hours', {
RESTIC_PROXY_THROTTLE_BYTES_PER_SEC: String(bytesPerSec),
RESTIC_PROXY_THROTTLE_QUIET_HOURS: '00:00-00:00',
});
expect(elapsedMs).toBeLessThan(throttledFloorMs);
}, 30_000);
});
+3
View File
@@ -0,0 +1,3 @@
import { describeResticProxy } from 'src/restic-proxy.suite';
describeResticProxy('restic proxy', false, 'restic-proxy-e2e');
@@ -0,0 +1,3 @@
import { describeResticProxy } from 'src/restic-proxy.suite';
describeResticProxy('restic WORM proxy', true, 'restic-worm-proxy-e2e');
+4 -25
View File
@@ -13,10 +13,8 @@ import {
updateRepository,
} from '@futo-org/backups-api-client';
import { init } from '@futo-org/restic-wrapper';
import { parse } from 'cookie';
import { env } from 'src/env';
import { yuccaBaseUrl as baseUrl, loginWithIdp } from 'src/yucca-auth';
const baseUrl = `http://localhost:${env.YUCCA_API_PORT}`;
const headers: Record<string, string> = {};
const authDto: AuthDto = {} as AuthDto;
@@ -24,32 +22,13 @@ const requestOpts = { baseUrl, headers };
describe('Auth', () => {
it('should log us in using IdP', async () => {
const { headers: loginHeaders } = await fetch(`${baseUrl}/api/auth/oidc/login`, {
redirect: 'manual',
});
const redirectUrl = new URL(loginHeaders.get('Location')!);
redirectUrl.pathname = '/api/form';
// Must differ from orchestration-api.spec's subject: these run in parallel,
// and one subject means one user, one repository list — which that suite
// enumerates and imports from.
redirectUrl.searchParams.set('sub', 'yucca-api-e2e');
const accessToken = await loginWithIdp('yucca-api-e2e');
expect(accessToken).toBeDefined();
const { headers: oidcHeaders } = await fetch(redirectUrl, {
redirect: 'manual',
});
const { headers: callbackHeaders } = await fetch(oidcHeaders.get('Location'), {
redirect: 'manual',
headers: {
Cookie: loginHeaders.getSetCookie().join('; '),
},
});
const cookies = parse(callbackHeaders.getSetCookie().join('; '));
expect(cookies['yucca-access-token']).toBeDefined();
headers['Cookie'] = `yucca-access-token=${cookies['yucca-access-token']}`;
headers['Cookie'] = `yucca-access-token=${accessToken}`;
});
it('should give us user information', async () => {
+5
View File
@@ -64,6 +64,11 @@ COPY --from=proxy /restic-proxy /usr/local/bin/
COPY --from=builder /deploy ./
COPY --chmod=0755 packages/standalone-app/docker-entrypoint.sh /usr/local/bin/
# orchestration-api silently falls back to direct restic when restic-proxy is
# not an executable on PATH. An invalid LOG_LEVEL exits 3 before binding, so
# this proves the binary runs on this platform without starting the proxy.
RUN LOG_LEVEL=invalid restic-proxy; [ $? -eq 3 ]
RUN mkdir -p /data /cache && chown node:node /data /cache /app
VOLUME /data
VOLUME /cache
+2 -1
View File
@@ -1705,7 +1705,8 @@
"incomplete",
"complete",
"warn",
"failed"
"failed",
"cancelled"
]
},
"RepositoryListResponseDto": {
@@ -1,6 +1,7 @@
import { MetricService } from '@common/server/otel';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { randomUUID } from 'node:crypto';
import request from 'supertest';
import { App } from 'supertest/types';
import { controllers, imports, providers } from '../src/app.module';
@@ -106,6 +107,65 @@ describe('ConnectionController (e2e)', () => {
});
});
describe('PATCH /connections/:id', () => {
it('renames a connection', async () => {
const instance = await testUtils.createConnection(user.id, 'immich', 'laptop');
await request(app.getHttpServer())
.patch(`/api/connections/${instance.id}`)
.set('Cookie', cookie())
.send({ name: 'desktop' })
.expect(204);
const { body } = await request(app.getHttpServer()).get('/api/connections').set('Cookie', cookie()).expect(200);
expect(body.connections).toEqual(
expect.arrayContaining([expect.objectContaining({ id: instance.id, type: 'immich', name: 'desktop' })]),
);
});
it.each([{}, { name: 42 }, { name: 'x'.repeat(121) }])('rejects an invalid body %j', async (dto) => {
await request(app.getHttpServer())
.patch(`/api/connections/${connection.id}`)
.set('Cookie', cookie())
.send(dto)
.expect(400);
const { body } = await request(app.getHttpServer()).get('/api/connections').set('Cookie', cookie()).expect(200);
expect(body.connections[0].name).toBe('Immich');
});
it('404s for a missing connection', async () => {
await request(app.getHttpServer())
.patch(`/api/connections/${randomUUID()}`)
.set('Cookie', cookie())
.send({ name: 'desktop' })
.expect(404);
});
it("refuses to rename other users' connections", async () => {
const other = await testUtils.createUser('other', 'other@example.com', 'other-sub');
await request(app.getHttpServer())
.patch(`/api/connections/${other.connection.id}`)
.set('Cookie', cookie())
.send({ name: 'hijacked' })
.expect(401);
const { body } = await request(app.getHttpServer())
.get('/api/connections')
.set('Cookie', `yucca-access-token=${other.session.accessToken}`)
.expect(200);
expect(body.connections[0].name).toBe('Immich');
});
it('rejects unauthenticated requests', async () => {
await request(app.getHttpServer())
.patch(`/api/connections/${connection.id}`)
.send({ name: 'desktop' })
.expect(401);
});
});
describe('DELETE /connections/:id', () => {
it('refuses to delete the default connection', async () => {
await request(app.getHttpServer())
@@ -1,4 +1,4 @@
import { MetricService } from '@common/server/otel';
import { LoggerRepository, MetricService } from '@common/server/otel';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
@@ -38,6 +38,54 @@ describe('MetricsController (e2e)', () => {
repository = await testUtils.createRepository(user.id);
});
describe('POST /metrics/submit/log', () => {
let loggerInfo: jest.SpyInstance;
beforeEach(() => {
loggerInfo = jest.spyOn(app.get(LoggerRepository), 'info');
});
afterEach(() => {
loggerInfo.mockRestore();
});
it('forwards a structured log to the logger', async () => {
await request(app.getHttpServer())
.post('/api/metrics/submit/log')
.set('Cookie', `yucca-access-token=${session.accessToken}`)
.send({ summary: 'backup finished', data: { files: 3 } })
.expect(204);
expect(loggerInfo).toHaveBeenCalledWith({
_msg: '[telemetry] backup finished',
customerId: user.id,
data: { files: 3 },
});
});
it.each([{}, { summary: 'backup finished' }, { summary: 'backup finished', data: 'files' }])(
'rejects an invalid body %j',
async (dto) => {
await request(app.getHttpServer())
.post('/api/metrics/submit/log')
.set('Cookie', `yucca-access-token=${session.accessToken}`)
.send(dto)
.expect(400);
expect(loggerInfo).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: user.id }));
},
);
it('rejects unauthenticated requests', async () => {
await request(app.getHttpServer())
.post('/api/metrics/submit/log')
.send({ summary: 'backup finished', data: {} })
.expect(401);
expect(loggerInfo).not.toHaveBeenCalledWith(expect.objectContaining({ _msg: '[telemetry] backup finished' }));
});
});
describe('GET /metrics/:repositoryId/history', () => {
it('reflects submitted metric changes in history', async () => {
await request(app.getHttpServer())
@@ -86,6 +134,31 @@ describe('MetricsController (e2e)', () => {
});
});
it('records a cancelled backup in history', async () => {
await request(app.getHttpServer())
.post(`/api/metrics/submit/${repository.id}/backup/end`)
.set('Cookie', `yucca-access-token=${session.accessToken}`)
.send({ status: 'cancelled', durationMs: 1234 })
.expect(204);
const { body } = await request(app.getHttpServer())
.get(`/api/metrics/${repository.id}/history`)
.set('Cookie', `yucca-access-token=${session.accessToken}`)
.expect(200);
expect(body).toEqual({
items: [
expect.objectContaining({
repositoryId: repository.id,
backup: expect.any(String),
backupStatus: 'cancelled',
backupDuration: 1234,
}),
],
nextCursor: null,
});
});
it('pages through history with limit and cursor', async () => {
for (const sizeBytes of [1024, 2048, 4096]) {
await request(app.getHttpServer())
@@ -154,5 +227,26 @@ describe('MetricsController (e2e)', () => {
},
});
});
it('reflects a cancelled backup on the repository', async () => {
await request(app.getHttpServer())
.post(`/api/metrics/submit/${repository.id}/backup/end`)
.set('Cookie', `yucca-access-token=${session.accessToken}`)
.send({ status: 'cancelled', durationMs: 1234 })
.expect(204);
const { body } = await request(app.getHttpServer())
.get(`/api/repository/${repository.id}`)
.set('Cookie', `yucca-access-token=${session.accessToken}`)
.expect(200);
expect(body.repository.metrics).toEqual(
expect.objectContaining({
lastBackup: expect.any(String),
lastBackupStatus: 'cancelled',
lastBackupDuration: 1234,
}),
);
});
});
});
@@ -0,0 +1,98 @@
import { FileMigrationProvider, Kysely, Migrator, sql } from 'kysely';
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { BackupStatus } from 'src/enum';
import { ConnectionRepository } from 'src/repositories/connection.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { RepositoryRepository } from 'src/repositories/repository.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
import { getKyselyConfig } from 'src/utils/database';
describe('AllowCancelledBackupStatus migration', () => {
const schema = `migration_test_${new CryptoRepository().randomHex(8)}`;
let adminDb: Kysely<DB>;
let db: Kysely<DB>;
let migrator: Migrator;
const getBackupStatuses = async () => {
const { rows } = await sql<{
status: string;
}>`SELECT unnest(enum_range(NULL::backup_status_enum))::text AS status`.execute(db);
return rows.map((row) => row.status);
};
beforeAll(async () => {
adminDb = new Kysely(getKyselyConfig());
await sql`CREATE SCHEMA ${sql.id(schema)}`.execute(adminDb);
db = new Kysely(getKyselyConfig({ connection: { TimeZone: 'UTC', search_path: schema } }));
migrator = new Migrator({
db,
migrationTableSchema: schema,
migrationLockTableName: 'kysely_migrations_lock',
migrationTableName: 'kysely_migrations',
provider: new FileMigrationProvider({
fs: { readdir },
path: { join },
migrationFolder: join(__dirname, '..', 'src/schema/migrations'),
}),
});
const { error } = await migrator.migrateToLatest();
expect(error).toBeUndefined();
});
afterAll(async () => {
await db.destroy();
await sql`DROP SCHEMA ${sql.id(schema)} CASCADE`.execute(adminDb);
await adminDb.destroy();
});
it('rewrites cancelled statuses to failed on down and restores cancelled on up', async () => {
const user = await new UserRepository(db).create({ name: 'foo', email: 'user@example.com', sub: 'foo' });
const connection = await new ConnectionRepository(db).getOrCreateDefault(user.id);
const repository = await new RepositoryRepository(db).create({
name: 'My Repository',
worm: false,
userId: user.id,
siteCode: 'local',
storageClusterCode: 'local-dev',
connectionId: connection.id,
});
await db
.insertInto('repositoryMetrics')
.values({ id: repository.id, sizeBytes: 0, lastBackupStatus: BackupStatus.Cancelled })
.execute();
await db
.insertInto('repositoryMetricsHistory')
.values({ repositoryId: repository.id, backupStatus: BackupStatus.Cancelled })
.execute();
const down = await migrator.migrateTo('20260916113500-BackupStatus');
expect(down.error).toBeUndefined();
expect(down.results).toContainEqual({
migrationName: '20260917121000-AllowCancelledBackupStatus',
direction: 'Down',
status: 'Success',
});
const metrics = await db.selectFrom('repositoryMetrics').select('lastBackupStatus').execute();
const history = await db.selectFrom('repositoryMetricsHistory').select('backupStatus').execute();
expect(metrics).toEqual([{ lastBackupStatus: 'failed' }]);
expect(history).toEqual([{ backupStatus: 'failed' }]);
const statusesAfterDown = await getBackupStatuses();
expect(statusesAfterDown).toEqual(['incomplete', 'complete', 'warn', 'failed']);
const up = await migrator.migrateTo('20260917121000-AllowCancelledBackupStatus');
expect(up.error).toBeUndefined();
expect(up.results).toContainEqual({
migrationName: '20260917121000-AllowCancelledBackupStatus',
direction: 'Up',
status: 'Success',
});
const statusesAfterUp = await getBackupStatuses();
expect(statusesAfterUp).toEqual(['incomplete', 'complete', 'warn', 'failed', 'cancelled']);
});
});
@@ -3,6 +3,8 @@ import { ZodValidationPipe } from 'nestjs-zod';
import { LoggingRepository, ORCHESTRATION_PORT, OrchestrationApiModule } from '../src';
async function bootstrap() {
const databaseDumpConfig = { enabled: true, keepLastAmount: 14 };
const app = await NestFactory.create(
OrchestrationApiModule.forRootAsync({
useFactory: () => ({
@@ -25,12 +27,14 @@ async function bootstrap() {
},
async cleanupDatabaseBackups() {},
async getImmichDatabaseDumpConfig() {
return { enabled: true, keepLastAmount: 14 };
return databaseDumpConfig;
},
async configureImmichDatabaseDump() {},
async enterMaintenanceRollback() {
async configureImmichDatabaseDump(config) {
Object.assign(databaseDumpConfig, config);
},
async enterMaintenanceRollback(repositoryId, snapshotId, backupFileName) {
return {
jwt: 'abc',
jwt: [repositoryId, snapshotId, backupFileName].join(':'),
};
},
},
@@ -0,0 +1,75 @@
import { createResticUrl } from '@futo-org/backups-api-client';
import { BackendType } from '../enum';
import { ResticProxy } from '../proxy/resticProxy';
import { ResticProxyPool } from '../proxy/resticProxyPool';
import { LoggingRepository } from '../repositories/logging.repository';
import { YuccaBackend } from './yucca.backend';
jest.mock('@futo-org/backups-api-client', () => ({ createResticUrl: jest.fn() }));
function newBackend() {
return new YuccaBackend({ type: BackendType.Yucca, url: 'https://yucca.test', accessToken: 'access-token' });
}
describe(YuccaBackend.name, () => {
const proxy = { createUrl: jest.fn(ResticProxy.prototype.createUrl), address: '127.0.0.1:4000' };
beforeEach(() => {
jest.mocked(createResticUrl).mockResolvedValue({ url: 'rest:https://direct.test/repository-id' });
});
afterEach(() => {
jest.restoreAllMocks();
jest.mocked(createResticUrl).mockReset();
proxy.createUrl.mockClear();
});
describe('getResticEndpoint', () => {
it('returns a proxy URL when the proxy is available', async () => {
jest.spyOn(ResticProxyPool.prototype, 'isAvailable').mockResolvedValue(true);
const getProxy = jest
.spyOn(ResticProxyPool.prototype, 'getProxy')
.mockResolvedValue(proxy as unknown as ResticProxy);
await expect(newBackend().getResticEndpoint('repository-id')).resolves.toEqual(
'rest:http://repository-id:access-token@127.0.0.1:4000',
);
expect(getProxy).toHaveBeenCalledWith('https://yucca.test/api');
expect(proxy.createUrl).toHaveBeenCalledWith('repository-id', 'access-token');
expect(createResticUrl).not.toHaveBeenCalled();
});
it('falls back to a direct URL and warns when the proxy fails to start', async () => {
const failure = new Error('restic-proxy did not report an address');
jest.spyOn(ResticProxyPool.prototype, 'isAvailable').mockResolvedValue(true);
jest.spyOn(ResticProxyPool.prototype, 'getProxy').mockRejectedValue(failure);
const warn = jest.spyOn(LoggingRepository.prototype, 'warn').mockImplementation(() => {});
await expect(newBackend().getResticEndpoint('repository-id')).resolves.toEqual(
'rest:https://direct.test/repository-id',
);
expect(createResticUrl).toHaveBeenCalledWith(
'repository-id',
expect.objectContaining({ baseUrl: 'https://yucca.test' }),
);
expect(warn).toHaveBeenCalledWith('Falling back to a direct restic URL', failure);
});
it('uses a direct URL without asking for a proxy when the proxy is unavailable', async () => {
jest.spyOn(ResticProxyPool.prototype, 'isAvailable').mockResolvedValue(false);
const getProxy = jest.spyOn(ResticProxyPool.prototype, 'getProxy');
await expect(newBackend().getResticEndpoint('repository-id')).resolves.toEqual(
'rest:https://direct.test/repository-id',
);
expect(createResticUrl).toHaveBeenCalledWith(
'repository-id',
expect.objectContaining({ baseUrl: 'https://yucca.test' }),
);
expect(getProxy).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,115 @@
import { EventEmitter } from 'node:events';
import { ResticProxy, UNTHROTTLED } from './resticProxy';
import { ResticProxyPool } from './resticProxyPool';
type FakeProxy = {
process: EventEmitter;
throttle: jest.Mock;
stop: jest.Mock;
};
const newFakeProxy = (): FakeProxy => ({
process: new EventEmitter(),
throttle: jest.fn(),
stop: jest.fn().mockResolvedValue(undefined),
});
describe(ResticProxyPool.name, () => {
const apiUrl = 'https://yucca.test/api';
const throttle = { bytesPerSec: 1000, quietHours: '01:00-02:00' };
let create: jest.SpyInstance;
beforeEach(() => {
ResticProxyPool.pool = new Map();
ResticProxyPool.available = undefined;
ResticProxyPool.throttle = UNTHROTTLED;
create = jest.spyOn(ResticProxy, 'create').mockImplementation(() => Promise.resolve(newFakeProxy() as never));
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('getProxy', () => {
it('creates a proxy with the current throttle', async () => {
ResticProxyPool.throttle = throttle;
await new ResticProxyPool().getProxy(apiUrl);
expect(create).toHaveBeenCalledWith(apiUrl, throttle);
});
it('returns the same pending proxy to concurrent callers', () => {
const pool = new ResticProxyPool();
const first = pool.getProxy(apiUrl);
const second = pool.getProxy(apiUrl);
expect(second).toBe(first);
expect(create).toHaveBeenCalledTimes(1);
});
it('creates a fresh proxy after the pooled one exits', async () => {
const pool = new ResticProxyPool();
const exited = await pool.getProxy(apiUrl);
exited.process.emit('exit');
const replacement = await pool.getProxy(apiUrl);
expect(replacement).not.toBe(exited);
expect(create).toHaveBeenCalledTimes(2);
});
it('retries after a proxy fails to start', async () => {
const pool = new ResticProxyPool();
create.mockRejectedValueOnce(new Error('restic-proxy did not report an address'));
await expect(pool.getProxy(apiUrl)).rejects.toThrow('restic-proxy did not report an address');
await expect(pool.getProxy(apiUrl)).resolves.toBeDefined();
expect(create).toHaveBeenCalledTimes(2);
});
});
describe('onModuleConfigUpdate', () => {
it('pushes the new throttle to every running proxy', async () => {
const pool = new ResticProxyPool();
const first = await pool.getProxy(apiUrl);
const second = await pool.getProxy('https://other.test/api');
await pool.onModuleConfigUpdate({ statePath: '/state', throttle });
expect(ResticProxyPool.throttle).toEqual(throttle);
expect(first.throttle).toHaveBeenCalledWith(throttle);
expect(second.throttle).toHaveBeenCalledWith(throttle);
});
it('unthrottles every running proxy when the throttle is removed', async () => {
const pool = new ResticProxyPool();
ResticProxyPool.throttle = throttle;
const running = await pool.getProxy(apiUrl);
await pool.onModuleConfigUpdate({ statePath: '/state' });
expect(ResticProxyPool.throttle).toEqual(UNTHROTTLED);
expect(running.throttle).toHaveBeenCalledWith(UNTHROTTLED);
});
});
describe('onApplicationShutdown', () => {
it('stops every running proxy', async () => {
const pool = new ResticProxyPool();
const first = await pool.getProxy(apiUrl);
const second = await pool.getProxy('https://other.test/api');
create.mockRejectedValueOnce(new Error('restic-proxy did not report an address'));
const failed = pool.getProxy('https://failed.test/api');
await pool.onApplicationShutdown();
await expect(failed).rejects.toThrow();
expect(first.stop).toHaveBeenCalled();
expect(second.stop).toHaveBeenCalled();
});
});
});
@@ -856,10 +856,27 @@ export class RepositoryService {
id: string,
dto: RepositoryPrimaryBackendReconfigureRequestDto,
): Promise<RepositoryCreateResponseDto> {
const local = await this.repository.get(id);
if (!local) {
throw new NotFoundException('Repository not found locally');
}
const { backend, configuration } = await this.getBackendOrThrow(dto.backendId);
let name = 'Restored Repository';
try {
const { backend: previous } = await this.getBackendOrThrow(local.backendId);
const { repository: previousRemote } = await previous.getRepository(local.remoteId);
name = previousRemote.name;
} catch (error) {
this.logger.warn(
`Could not read the name of repository ${local.remoteId} from backend ${local.backendId}`,
error,
);
}
const { repository: remote } = await backend.createRepository({
name: 'Restored Repository',
name,
worm: false,
});
@@ -0,0 +1,63 @@
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ConfigController } from 'src/controllers/config.controller';
import { InternalEvent } from 'src/enum';
import { ModuleConfig } from 'src/moduleConfig';
import { UNTHROTTLED } from 'src/proxy/resticProxy';
import { ConfigRepository } from 'src/repositories/config.repository';
import { ModuleConfigRepository } from 'src/repositories/moduleConfig.repository';
import { ConfigService } from 'src/services/config.service';
import { createTestingModule, TestContext } from './testUtils';
let ctx: TestContext;
beforeEach(async () => {
ctx = await createTestingModule();
}, 15_000);
afterEach(async () => {
await ctx.app.close();
});
describe('Config service', () => {
const bandwidth = { bytesPerSec: 5_000_000, quietHours: '22:00-06:00' };
it('reports unthrottled until a throttle is set', () => {
expect(ctx.module.get(ConfigService).getConfig()).toEqual({ bandwidth: UNTHROTTLED });
});
it('loads a stored throttle on bootstrap', async () => {
const service = ctx.module.get(ConfigService);
await ctx.module.get(ConfigRepository).setThrottle(bandwidth);
await service.bootstrap();
expect(service.getConfig()).toEqual({ bandwidth });
});
it('stores an updated throttle and reports it back', async () => {
const service = ctx.module.get(ConfigService);
await expect(service.updateConfig({ bandwidth })).resolves.toEqual({ bandwidth });
expect(service.getConfig()).toEqual({ bandwidth });
await expect(ctx.module.get(ConfigRepository).getThrottle()).resolves.toEqual(bandwidth);
});
it('announces an updated throttle to module config listeners', async () => {
const updates: ModuleConfig[] = [];
ctx.module.get(EventEmitter2).on(InternalEvent.ModuleConfigUpdated, (config: ModuleConfig) => updates.push(config));
await ctx.module.get(ConfigService).updateConfig({ bandwidth });
expect(updates).toEqual([expect.objectContaining({ throttle: bandwidth })]);
expect(ctx.module.get(ModuleConfigRepository).get().throttle).toEqual(bandwidth);
});
it('serves the throttle through the config controller', async () => {
const controller = ctx.module.get(ConfigController);
await expect(controller.updateConfig({ bandwidth })).resolves.toEqual({ bandwidth });
expect(controller.getConfig()).toEqual({ bandwidth });
});
});
@@ -0,0 +1,84 @@
import Database from 'better-sqlite3';
import { FileMigrationProvider, Kysely, Migrator, SqliteDialect } from 'kysely';
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
type Migrated = {
database: InstanceType<typeof Database>;
migrator: Migrator;
close: () => Promise<void>;
};
async function migrateToLatest(): Promise<Migrated> {
const database = new Database(':memory:');
const db = new Kysely<unknown>({ dialect: new SqliteDialect({ database }) });
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs: { readdir },
path: { join },
migrationFolder: join(__dirname, '..', 'src/schema/migrations'),
}),
});
const { error } = await migrator.migrateToLatest();
if (error) {
throw error;
}
return { database, migrator, close: () => db.destroy() };
}
let migrated: Migrated;
beforeEach(async () => {
migrated = await migrateToLatest();
migrated.database.prepare(`INSERT INTO backends (id, configuration) VALUES ('backend', '{}')`).run();
migrated.database.prepare(`INSERT INTO repositories (id, backendId) VALUES ('repository', 'backend')`).run();
});
afterEach(async () => {
await migrated.close();
});
function insertCancelledRun(id: string) {
migrated.database
.prepare(`INSERT INTO runHistory (id, repositoryId, status) VALUES (?, 'repository', 'cancelled')`)
.run(id);
}
function insertCancelledMetrics() {
migrated.database
.prepare(`INSERT INTO repositoryLocalMetrics (id, lastBackupStatus) VALUES ('repository', 'cancelled')`)
.run();
}
async function migrateDownToLastBackupStatus() {
const { error } = await migrated.migrator.migrateTo('20260814120100-AddLastBackupStatus');
if (error) {
throw error;
}
}
describe('AllowCancelledStatus migration', () => {
it('rewrites cancelled statuses as failed when migrating down', async () => {
insertCancelledRun('run');
insertCancelledMetrics();
await migrateDownToLastBackupStatus();
expect(migrated.database.prepare(`SELECT id, status FROM runHistory`).all()).toEqual([
{ id: 'run', status: 'failed' },
]);
expect(migrated.database.prepare(`SELECT id, lastBackupStatus FROM repositoryLocalMetrics`).all()).toEqual([
{ id: 'repository', lastBackupStatus: 'failed' },
]);
});
it('rejects cancelled statuses after migrating down', async () => {
await migrateDownToLastBackupStatus();
expect(() => insertCancelledRun('run')).toThrow(/CHECK constraint failed/);
expect(() => insertCancelledMetrics()).toThrow(/CHECK constraint failed/);
});
});
@@ -1,7 +1,9 @@
import { createRepository, createResticUrl, getRepository } from '@futo-org/backups-api-client';
import { ResticBackupCommandCouldNotReadSourceDataError } from '@futo-org/restic-wrapper';
import { randomUUID } from 'node:crypto';
import { mkdir } from 'node:fs/promises';
import { TaskStatus } from 'src/enum';
import { mkdir, readdir } from 'node:fs/promises';
import { BackendType, TaskStatus } from 'src/enum';
import { ResticProxyPool } from 'src/proxy/resticProxyPool';
import { BackendRepository } from 'src/repositories/backend.repository';
import { ModuleConfigRepository } from 'src/repositories/moduleConfig.repository';
import { RepositoryIntegrationImmichRepository } from 'src/repositories/repositoryIntegrationImmich.repository';
@@ -335,3 +337,77 @@ describe('Repository', () => {
await task;
});
});
const remoteRepository = (id: string, name: string) => ({
repository: {
id,
name,
worm: false,
siteCode: null,
storageClusterCode: null,
connectionId: '',
connectionType: 'restic',
metrics: { sizeBytes: 0 },
},
});
describe('Primary backend reconfiguration', () => {
beforeEach(() => {
ctx.resticMock.init.mockReset();
jest.spyOn(ResticProxyPool.prototype, 'isAvailable').mockResolvedValue(false);
(createResticUrl as jest.Mock).mockResolvedValue({ url: 'rest:http://yucca.test/restic' });
});
afterEach(() => {
jest.restoreAllMocks();
});
it('keeps the repository name when moving to another backend', async () => {
const repositoryService = ctx.module.get(RepositoryService);
const yuccaBackendId = randomUUID();
await ctx.module.get(BackendRepository).updateBackend(yuccaBackendId, {
type: BackendType.Yucca,
url: 'http://yucca.test',
accessToken: 'test-token',
});
const remoteId = randomUUID();
(createRepository as jest.Mock).mockResolvedValueOnce(remoteRepository(remoteId, 'Family Photos'));
(getRepository as jest.Mock).mockResolvedValueOnce(remoteRepository(remoteId, 'Family Photos'));
const { repository } = await repositoryService.createRepository(
{ name: 'Family Photos', worm: false },
yuccaBackendId,
);
const { repository: moved } = await repositoryService.reconfigureRepositoryPrimaryBackend(repository.id, {
backendId: ctx.backendId,
});
expect(getRepository).toHaveBeenCalledWith(remoteId, expect.any(Object));
expect(moved).toEqual(expect.objectContaining({ id: repository.id, name: 'Family Photos' }));
});
it('names the repository as restored when the old backend cannot report its name', async () => {
const repositoryService = ctx.module.get(RepositoryService);
const { repository } = await repositoryService.createRepository({ name: 'Local Only', worm: false }, ctx.backendId);
const { repository: moved } = await repositoryService.reconfigureRepositoryPrimaryBackend(repository.id, {
backendId: ctx.backendId,
});
expect(moved.name).toBe('Restored Repository');
});
it('rejects an unknown repository before creating anything on the new backend', async () => {
const repositoryService = ctx.module.get(RepositoryService);
const entriesBefore = await readdir(ctx.backendPath);
await expect(
repositoryService.reconfigureRepositoryPrimaryBackend(randomUUID(), { backendId: ctx.backendId }),
).rejects.toThrow('Repository not found locally');
await expect(readdir(ctx.backendPath)).resolves.toEqual(entriesBefore);
expect(ctx.resticMock.init).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,90 @@
import ImmichDatabaseDumpAlert from '$lib/components/integrations/immich/ImmichDatabaseDumpAlert.svelte';
import {
ignoreImmichDatabaseDumpWarning,
type ImmichBackupStatusDto,
} from '$lib/fetch-client';
import { queryClient } from '$lib/query-client';
import {
immichBackupStatusKeys,
useIgnoreImmichDatabaseDumpWarning,
} from '$lib/services/immich.integration.service';
import { render } from 'svelte/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('$lib/fetch-client');
const schedule = {
id: 'schedule',
name: 'Immich',
paused: false,
cron: '0 2 * * *',
repositories: [],
};
const status = (
overrides: Partial<ImmichBackupStatusDto> = {},
): ImmichBackupStatusDto => ({
databaseDump: { enabled: true, keepLastAmount: 14 },
schedule,
...overrides,
});
const isRendered = () => {
const { body } = render(ImmichDatabaseDumpAlert);
return body.replaceAll(/<!--.*?-->/g, '') !== '';
};
const isAlertShown = (backupStatus: ImmichBackupStatusDto) => {
queryClient.setQueryData(immichBackupStatusKeys.all, backupStatus);
return isRendered();
};
describe('ImmichDatabaseDumpAlert', () => {
beforeEach(() => {
queryClient.clear();
vi.clearAllMocks();
});
it('shows while Immich database dumps are enabled and not ignored', () => {
expect(isAlertShown(status())).toBe(true);
});
it('hides once the warning has been ignored', () => {
expect(isAlertShown(status({ databaseDumpWarningIgnored: true }))).toBe(
false,
);
});
it('hides when Immich database dumps are disabled', () => {
expect(
isAlertShown(
status({ databaseDump: { enabled: false, keepLastAmount: 14 } }),
),
).toBe(false);
});
it('hides while the backup schedule is paused', () => {
expect(
isAlertShown(status({ schedule: { ...schedule, paused: true } })),
).toBe(false);
});
it('ignoring the warning calls the SDK and hides the alert', async () => {
expect(isAlertShown(status())).toBe(true);
useIgnoreImmichDatabaseDumpWarning().mutate();
await vi.waitFor(() =>
expect(
queryClient.getQueryData<ImmichBackupStatusDto>(
immichBackupStatusKeys.all,
)?.databaseDumpWarningIgnored,
).toBe(true),
);
expect(ignoreImmichDatabaseDumpWarning).toHaveBeenCalledOnce();
expect(isRendered()).toBe(false);
});
});
@@ -1,6 +1,14 @@
<script lang="ts">
import Accordion from "$lib/components/ui/Accordion.svelte";
import { useConfig, useUpdateConfig } from "$lib/services/config.service";
import {
defaultQuietHoursEnd,
defaultQuietHoursStart,
toBandwidthDto,
toBandwidthForm,
unlimitedBandwidth,
useConfig,
useUpdateConfig,
} from "$lib/services/config.service";
import {
Button,
Field,
@@ -14,10 +22,9 @@
import { mdiSpeedometerSlow } from "@mdi/js";
const bytesPerMegabyte = 1_000_000;
const unlimited = "0";
const speeds = [
{ value: unlimited, label: "No limit" },
{ value: unlimitedBandwidth, label: "No limit" },
...[0.5, 1, 2, 5, 10, 20, 50, 100].map((megabytes) => ({
value: String(megabytes * bytesPerMegabyte),
label: `${megabytes} MB/s`,
@@ -29,13 +36,10 @@
label: `${String(hour).padStart(2, "0")}:00`,
}));
const defaultQuietHoursStart = "22:00";
const defaultQuietHoursEnd = "06:00";
const config = useConfig();
const mutation = useUpdateConfig();
let speed = $state(unlimited);
let speed = $state(unlimitedBandwidth);
let quiet = $state(false);
let quietStart = $state(defaultQuietHoursStart);
let quietEnd = $state(defaultQuietHoursEnd);
@@ -46,14 +50,9 @@
return;
}
const { bytesPerSec, quietHours } = config.data.bandwidth;
const [start = defaultQuietHoursStart, end = defaultQuietHoursEnd] =
quietHours?.split("-") ?? [];
speed = String(bytesPerSec);
quiet = Boolean(quietHours);
quietStart = start;
quietEnd = end;
({ speed, quiet, quietStart, quietEnd } = toBandwidthForm(
config.data.bandwidth,
));
};
$effect(() => {
@@ -65,14 +64,11 @@
load();
});
const limited = $derived(speed !== unlimited);
const limited = $derived(speed !== unlimitedBandwidth);
const onSave = () => {
mutation.mutate({
bandwidth: {
bytesPerSec: Number(speed),
quietHours: limited && quiet ? `${quietStart}-${quietEnd}` : undefined,
},
bandwidth: toBandwidthDto({ speed, quiet, quietStart, quietEnd }),
});
};
</script>
@@ -0,0 +1,123 @@
import { updateConfig, type ConfigResponseDto } from '$lib/fetch-client';
import { queryClient } from '$lib/query-client';
import {
configKeys,
toBandwidthDto,
toBandwidthForm,
useUpdateConfig,
} from '$lib/services/config.service';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('$lib/fetch-client');
describe('toBandwidthForm', () => {
it('reads the limit and quiet hours from the saved config', () => {
expect(
toBandwidthForm({ bytesPerSec: 5_000_000, quietHours: '23:00-07:00' }),
).toEqual({
speed: '5000000',
quiet: true,
quietStart: '23:00',
quietEnd: '07:00',
});
});
it('falls back to the default quiet hours when none are saved', () => {
expect(toBandwidthForm({ bytesPerSec: 0 })).toEqual({
speed: '0',
quiet: false,
quietStart: '22:00',
quietEnd: '06:00',
});
});
});
describe('toBandwidthDto', () => {
it('converts the selected speed to bytes per second with quiet hours', () => {
expect(
toBandwidthDto({
speed: '500000',
quiet: true,
quietStart: '22:00',
quietEnd: '06:00',
}),
).toEqual({ bytesPerSec: 500_000, quietHours: '22:00-06:00' });
});
it('clears quiet hours when they are switched off', () => {
expect(
toBandwidthDto({
speed: '2000000',
quiet: false,
quietStart: '22:00',
quietEnd: '06:00',
}),
).toEqual({ bytesPerSec: 2_000_000, quietHours: undefined });
});
it('disables the limit and drops quiet hours when set to no limit', () => {
expect(
toBandwidthDto({
speed: '0',
quiet: true,
quietStart: '22:00',
quietEnd: '06:00',
}),
).toEqual({ bytesPerSec: 0, quietHours: undefined });
});
it('round trips a saved config', () => {
const bandwidth = { bytesPerSec: 10_000_000, quietHours: '01:00-05:00' };
expect(toBandwidthDto(toBandwidthForm(bandwidth))).toEqual(bandwidth);
});
});
describe('useUpdateConfig', () => {
beforeEach(() => {
queryClient.clear();
vi.clearAllMocks();
});
it('sends the bandwidth through the SDK and caches the saved config', async () => {
const saved: ConfigResponseDto = {
bandwidth: { bytesPerSec: 1_000_000, quietHours: '22:00-06:00' },
};
vi.mocked(updateConfig).mockResolvedValue(saved);
useUpdateConfig().mutate({
bandwidth: toBandwidthDto({
speed: '1000000',
quiet: true,
quietStart: '22:00',
quietEnd: '06:00',
}),
});
await vi.waitFor(() =>
expect(queryClient.getQueryData(configKeys.all)).toEqual(saved),
);
expect(updateConfig).toHaveBeenCalledWith(saved);
});
it('sends a zero limit without quiet hours to disable throttling', async () => {
const saved: ConfigResponseDto = { bandwidth: { bytesPerSec: 0 } };
vi.mocked(updateConfig).mockResolvedValue(saved);
useUpdateConfig().mutate({
bandwidth: toBandwidthDto({
speed: '0',
quiet: true,
quietStart: '22:00',
quietEnd: '06:00',
}),
});
await vi.waitFor(() =>
expect(queryClient.getQueryData(configKeys.all)).toEqual(saved),
);
expect(updateConfig).toHaveBeenCalledWith({
bandwidth: { bytesPerSec: 0, quietHours: undefined },
});
});
});
@@ -1,6 +1,7 @@
import {
getConfig,
updateConfig,
type BandwidthDto,
type ConfigUpdateRequestDto,
} from '$lib/fetch-client';
import { queryClient } from '$lib/query-client';
@@ -11,6 +12,45 @@ export const configKeys = {
all: ['config'] as const,
};
export const unlimitedBandwidth = '0';
export const defaultQuietHoursStart = '22:00';
export const defaultQuietHoursEnd = '06:00';
export type BandwidthForm = {
speed: string;
quiet: boolean;
quietStart: string;
quietEnd: string;
};
export const toBandwidthForm = ({
bytesPerSec,
quietHours,
}: BandwidthDto): BandwidthForm => {
const [quietStart = defaultQuietHoursStart, quietEnd = defaultQuietHoursEnd] =
quietHours?.split('-') ?? [];
return {
speed: String(bytesPerSec),
quiet: Boolean(quietHours),
quietStart,
quietEnd,
};
};
export const toBandwidthDto = ({
speed,
quiet,
quietStart,
quietEnd,
}: BandwidthForm): BandwidthDto => ({
bytesPerSec: Number(speed),
quietHours:
speed !== unlimitedBandwidth && quiet
? `${quietStart}-${quietEnd}`
: undefined,
});
export const useConfig = () =>
createQuery(
() => ({