mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
test: just a bunch of tests (#709)
This commit is contained in:
@@ -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 "$@"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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'];
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+90
@@ -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);
|
||||
});
|
||||
});
|
||||
+16
-20
@@ -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(
|
||||
() => ({
|
||||
|
||||
Reference in New Issue
Block a user