mirror of
https://github.com/immich-app/immich.git
synced 2026-02-14 12:58:17 +03:00
merge: remote-tracking branch 'origin/main' into feat/integrity-checks-izzy
This commit is contained in:
@@ -27,7 +27,7 @@
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^24.10.4",
|
||||
"@types/node": "^24.10.8",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
||||
@@ -8,7 +8,7 @@ dotenv.config({ path: resolve(import.meta.dirname, '.env') });
|
||||
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
|
||||
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
|
||||
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
|
||||
export const playwriteSlowMo = parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
|
||||
export const playwriteSlowMo = Number.parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
|
||||
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
|
||||
|
||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
|
||||
@@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = {
|
||||
testMatch: /.*\.e2e-spec\.ts/,
|
||||
workers: 1,
|
||||
},
|
||||
{
|
||||
name: 'parallel tests',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
testMatch: /.*\.parallel-e2e-spec\.ts/,
|
||||
fullyParallel: true,
|
||||
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||
},
|
||||
// {
|
||||
// name: 'parallel tests',
|
||||
// use: { ...devices['Desktop Chrome'] },
|
||||
// testMatch: /.*\.parallel-e2e-spec\.ts/,
|
||||
// fullyParallel: true,
|
||||
// workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||
// },
|
||||
|
||||
// {
|
||||
// name: 'firefox',
|
||||
|
||||
350
e2e/src/api/specs/database-backups.e2e-spec.ts
Normal file
350
e2e/src/api/specs/database-backups.e2e-spec.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { LoginResponseDto, ManualJobName } from '@immich/sdk';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/admin/database-backups', () => {
|
||||
let cookie: string | undefined;
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
});
|
||||
|
||||
describe('GET /', async () => {
|
||||
it('should succeed and be empty', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/database-backups')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
backups: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should contain a created backup', async () => {
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.BackupDatabase,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'backupDatabase');
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/database-backups')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
return body;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toEqual(
|
||||
expect.objectContaining({
|
||||
backups: [
|
||||
expect.objectContaining({
|
||||
filename: expect.stringMatching(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/),
|
||||
filesize: expect.any(Number),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /', async () => {
|
||||
it('should delete backup', async () => {
|
||||
const filename = await utils.createBackup(admin.accessToken);
|
||||
|
||||
const { status } = await request(app)
|
||||
.delete(`/admin/database-backups`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ backups: [filename] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
const { status: listStatus, body } = await request(app)
|
||||
.get('/admin/database-backups')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(listStatus).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
backups: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// => action: restore database flow
|
||||
|
||||
describe.sequential('POST /start-restore', () => {
|
||||
afterAll(async () => {
|
||||
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({ action: 'end' });
|
||||
await utils.poll(
|
||||
() => request(app).get('/server/config'),
|
||||
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||
);
|
||||
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
it.sequential('should not work when the server is configured', async () => {
|
||||
const { status, body } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('The server already has an admin'));
|
||||
});
|
||||
|
||||
it.sequential('should enter maintenance mode in "database restore mode"', async () => {
|
||||
await utils.resetDatabase(); // reset database before running this test
|
||||
|
||||
const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status2).toBe(200);
|
||||
expect(body).toEqual({
|
||||
active: true,
|
||||
action: 'select_database_restore',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// => action: restore database
|
||||
|
||||
describe.sequential('POST /backups/restore', () => {
|
||||
beforeAll(async () => {
|
||||
await utils.disconnectDatabase();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await utils.connectDatabase();
|
||||
});
|
||||
|
||||
it.sequential('should restore a backup', { timeout: 60_000 }, async () => {
|
||||
let filename = await utils.createBackup(admin.accessToken);
|
||||
|
||||
// work-around until test is running on released version
|
||||
await utils.move(
|
||||
`/data/backups/${filename}`,
|
||||
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
|
||||
);
|
||||
filename = 'immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz';
|
||||
|
||||
const { status } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
action: 'restore_database',
|
||||
restoreBackupFilename: filename,
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status2).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 60_000,
|
||||
},
|
||||
)
|
||||
.toBeFalsy();
|
||||
});
|
||||
|
||||
it.sequential('fail to restore a corrupted backup', { timeout: 60_000 }, async () => {
|
||||
await utils.prepareTestBackup('corrupted');
|
||||
|
||||
const { status, headers } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
action: 'restore_database',
|
||||
restoreBackupFilename: 'development-corrupted.sql.gz',
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status).toBe(200);
|
||||
return body;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
error: 'Something went wrong, see logs!',
|
||||
}),
|
||||
);
|
||||
|
||||
const { status: status2, body: body2 } = await request(app)
|
||||
.get('/admin/maintenance/status')
|
||||
.set('cookie', cookie!)
|
||||
.send({ token: 'token' });
|
||||
expect(status2).toBe(200);
|
||||
expect(body2).toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
error: expect.stringContaining('IM CORRUPTED'),
|
||||
}),
|
||||
);
|
||||
|
||||
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
|
||||
action: 'end',
|
||||
});
|
||||
|
||||
await utils.poll(
|
||||
() => request(app).get('/server/config'),
|
||||
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('rollback to restore point if backup is missing admin', { timeout: 60_000 }, async () => {
|
||||
await utils.prepareTestBackup('empty');
|
||||
|
||||
const { status, headers } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
action: 'restore_database',
|
||||
restoreBackupFilename: 'development-empty.sql.gz',
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status).toBe(200);
|
||||
return body;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 30_000,
|
||||
},
|
||||
)
|
||||
.toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
error: 'Something went wrong, see logs!',
|
||||
}),
|
||||
);
|
||||
|
||||
const { status: status2, body: body2 } = await request(app)
|
||||
.get('/admin/maintenance/status')
|
||||
.set('cookie', cookie!)
|
||||
.send({ token: 'token' });
|
||||
expect(status2).toBe(200);
|
||||
expect(body2).toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
error: expect.stringContaining('Server health check failed, no admin exists.'),
|
||||
}),
|
||||
);
|
||||
|
||||
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
|
||||
action: 'end',
|
||||
});
|
||||
|
||||
await utils.poll(
|
||||
() => request(app).get('/server/config'),
|
||||
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ describe('/admin/maintenance', () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
});
|
||||
|
||||
// => outside of maintenance mode
|
||||
@@ -26,6 +27,17 @@ describe('/admin/maintenance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /status', async () => {
|
||||
it('to always indicate we are not in maintenance mode', async () => {
|
||||
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
active: false,
|
||||
action: 'end',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /login', async () => {
|
||||
it('should not work out of maintenance mode', async () => {
|
||||
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
|
||||
@@ -39,6 +51,7 @@ describe('/admin/maintenance', () => {
|
||||
describe.sequential('POST /', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post('/admin/maintenance').send({
|
||||
active: false,
|
||||
action: 'end',
|
||||
});
|
||||
expect(status).toBe(401);
|
||||
@@ -69,6 +82,7 @@ describe('/admin/maintenance', () => {
|
||||
.send({
|
||||
action: 'start',
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
@@ -79,7 +93,8 @@ describe('/admin/maintenance', () => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { body } = await request(app).get('/server/config');
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
@@ -102,6 +117,17 @@ describe('/admin/maintenance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /status', async () => {
|
||||
it('to indicate we are in maintenance mode', async () => {
|
||||
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
active: true,
|
||||
action: 'start',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /login', async () => {
|
||||
it('should fail without cookie or token in body', async () => {
|
||||
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
|
||||
@@ -158,7 +184,8 @@ describe('/admin/maintenance', () => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { body } = await request(app).get('/server/config');
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
|
||||
@@ -348,6 +348,7 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
|
||||
checksum: asset.checksum,
|
||||
width: exifInfo.exifImageWidth ?? 1,
|
||||
height: exifInfo.exifImageHeight ?? 1,
|
||||
isEdited: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CreateLibraryDto,
|
||||
JobCreateDto,
|
||||
MaintenanceAction,
|
||||
ManualJobName,
|
||||
MetadataSearchDto,
|
||||
Permission,
|
||||
PersonCreateDto,
|
||||
@@ -30,10 +31,12 @@ import {
|
||||
createStack,
|
||||
createUserAdmin,
|
||||
deleteAssets,
|
||||
deleteDatabaseBackup,
|
||||
getAssetInfo,
|
||||
getConfig,
|
||||
getConfigDefaults,
|
||||
getQueuesLegacy,
|
||||
listDatabaseBackups,
|
||||
login,
|
||||
runQueueCommandLegacy,
|
||||
scanLibrary,
|
||||
@@ -62,6 +65,7 @@ import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
|
||||
import { promisify } from 'node:util';
|
||||
import { createGzip } from 'node:zlib';
|
||||
import pg from 'pg';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { loginDto, signupDto } from 'src/fixtures';
|
||||
@@ -89,8 +93,9 @@ export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer $
|
||||
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
||||
export const immichCli = (args: string[]) =>
|
||||
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
|
||||
export const immichAdmin = (args: string[]) =>
|
||||
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
|
||||
export const dockerExec = (args: string[]) =>
|
||||
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', args.join(' ')]);
|
||||
export const immichAdmin = (args: string[]) => dockerExec([`immich-admin ${args.join(' ')}`]);
|
||||
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
|
||||
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
|
||||
@@ -154,12 +159,26 @@ const onEvent = ({ event, id }: { event: EventType; id: string }) => {
|
||||
};
|
||||
|
||||
export const utils = {
|
||||
connectDatabase: async () => {
|
||||
if (!client) {
|
||||
client = new pg.Client(dbUrl);
|
||||
client.on('end', () => (client = null));
|
||||
client.on('error', () => (client = null));
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
return client;
|
||||
},
|
||||
|
||||
disconnectDatabase: async () => {
|
||||
if (client) {
|
||||
await client.end();
|
||||
}
|
||||
},
|
||||
|
||||
resetDatabase: async (tables?: string[]) => {
|
||||
try {
|
||||
if (!client) {
|
||||
client = new pg.Client(dbUrl);
|
||||
await client.connect();
|
||||
}
|
||||
client = await utils.connectDatabase();
|
||||
|
||||
tables = tables || [
|
||||
// TODO e2e test for deleting a stack, since it is quite complex
|
||||
@@ -616,6 +635,36 @@ export const utils = {
|
||||
return executeCommand('docker', ['exec', 'immich-e2e-server', 'mkdir', '-p', path]).promise;
|
||||
},
|
||||
|
||||
createBackup: async (accessToken: string) => {
|
||||
await utils.createJob(accessToken, {
|
||||
name: ManualJobName.BackupDatabase,
|
||||
});
|
||||
|
||||
return utils.poll(
|
||||
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
|
||||
({ status, body }) => status === 200 && body.backups.length === 1,
|
||||
({ body }) => body.backups[0].filename,
|
||||
);
|
||||
},
|
||||
|
||||
resetBackups: async (accessToken: string) => {
|
||||
const { backups } = await listDatabaseBackups({ headers: asBearerAuth(accessToken) });
|
||||
await deleteDatabaseBackup({ databaseBackupDeleteDto: { backups } }, { headers: asBearerAuth(accessToken) });
|
||||
},
|
||||
|
||||
prepareTestBackup: async (generate: 'empty' | 'corrupted') => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'test-'));
|
||||
const fn = join(dir, 'file');
|
||||
|
||||
const sql = Readable.from(generate === 'corrupted' ? 'IM CORRUPTED;' : 'SELECT 1;');
|
||||
const gzip = createGzip();
|
||||
const writeStream = createWriteStream(fn);
|
||||
await pipeline(sql, gzip, writeStream);
|
||||
|
||||
await executeCommand('docker', ['cp', fn, `immich-e2e-server:/data/backups/development-${generate}.sql.gz`])
|
||||
.promise;
|
||||
},
|
||||
|
||||
resetAdminConfig: async (accessToken: string) => {
|
||||
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
|
||||
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
||||
@@ -658,6 +707,25 @@ export const utils = {
|
||||
await utils.waitForQueueFinish(accessToken, 'sidecar');
|
||||
await utils.waitForQueueFinish(accessToken, 'metadataExtraction');
|
||||
},
|
||||
|
||||
async poll<T>(cb: () => Promise<T>, validate: (value: T) => boolean, map?: (value: T) => any) {
|
||||
let timeout = 0;
|
||||
while (true) {
|
||||
try {
|
||||
const data = await cb();
|
||||
if (validate(data)) {
|
||||
return map ? map(data) : data;
|
||||
}
|
||||
timeout++;
|
||||
if (timeout >= 10) {
|
||||
throw 'Could not clean up test.';
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5e2));
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
utils.initSdk();
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
|
||||
import { utils } from 'src/utils';
|
||||
import { assetViewerUtils, cancelAllPollers } from 'src/web/specs/timeline/utils';
|
||||
import { assetViewerUtils } from 'src/web/specs/timeline/utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('asset-viewer', () => {
|
||||
@@ -49,7 +49,6 @@ test.describe('asset-viewer', () => {
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
cancelAllPollers();
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
|
||||
105
e2e/src/web/specs/database-backups.e2e-spec.ts
Normal file
105
e2e/src/web/specs/database-backups.e2e-spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Database Backups', () => {
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
test('restore a backup from settings', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
const filename = await utils.createBackup(admin.accessToken);
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
// work-around until test is running on released version
|
||||
await utils.move(
|
||||
`/data/backups/${filename}`,
|
||||
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
|
||||
);
|
||||
|
||||
await page.goto('/admin/maintenance?isOpen=backups');
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
await page.waitForURL('/maintenance?**');
|
||||
await page.waitForURL('/admin/maintenance**', { timeout: 60_000 });
|
||||
});
|
||||
|
||||
test('handle backup restore failure', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
await utils.prepareTestBackup('corrupted');
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto('/admin/maintenance?isOpen=backups');
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
await page.waitForURL('/maintenance?**');
|
||||
await expect(page.getByText('IM CORRUPTED')).toBeVisible({ timeout: 60_000 });
|
||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||
await page.waitForURL('/admin/maintenance**');
|
||||
});
|
||||
|
||||
test('rollback to restore point if backup is missing admin', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
await utils.prepareTestBackup('empty');
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto('/admin/maintenance?isOpen=backups');
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
await page.waitForURL('/maintenance?**');
|
||||
await expect(page.getByText('Server health check failed, no admin exists.')).toBeVisible({ timeout: 60_000 });
|
||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||
await page.waitForURL('/admin/maintenance**');
|
||||
});
|
||||
|
||||
test('restore a backup from onboarding', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
const filename = await utils.createBackup(admin.accessToken);
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
// work-around until test is running on released version
|
||||
await utils.move(
|
||||
`/data/backups/${filename}`,
|
||||
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
|
||||
);
|
||||
|
||||
await utils.resetDatabase();
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Restore from backup' }).click();
|
||||
|
||||
try {
|
||||
await page.waitForURL('/maintenance**');
|
||||
} catch {
|
||||
// when chained with the rest of the tests
|
||||
// this navigation may fail..? not sure why...
|
||||
await page.goto('/maintenance');
|
||||
await page.waitForURL('/maintenance**');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
await page.waitForURL('/maintenance?**');
|
||||
await page.waitForURL('/photos', { timeout: 60_000 });
|
||||
});
|
||||
});
|
||||
@@ -16,12 +16,12 @@ test.describe('Maintenance', () => {
|
||||
test('enter and exit maintenance mode', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto('/admin/system-settings?isOpen=maintenance');
|
||||
await page.getByRole('button', { name: 'Start maintenance mode' }).click();
|
||||
await page.goto('/admin/maintenance');
|
||||
await page.getByRole('button', { name: 'Switch to maintenance mode' }).click();
|
||||
|
||||
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||
await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 });
|
||||
await page.waitForURL('**/admin/maintenance*', { timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
|
||||
|
||||
@@ -18,7 +18,6 @@ import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } fro
|
||||
import { utils } from 'src/utils';
|
||||
import {
|
||||
assetViewerUtils,
|
||||
cancelAllPollers,
|
||||
padYearMonth,
|
||||
pageUtils,
|
||||
poll,
|
||||
@@ -64,7 +63,6 @@ test.describe('Timeline', () => {
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
cancelAllPollers();
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
|
||||
@@ -23,13 +23,6 @@ export async function throttlePage(context: BrowserContext, page: Page) {
|
||||
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
|
||||
}
|
||||
|
||||
let activePollsAbortController = new AbortController();
|
||||
|
||||
export const cancelAllPollers = () => {
|
||||
activePollsAbortController.abort();
|
||||
activePollsAbortController = new AbortController();
|
||||
};
|
||||
|
||||
export const poll = async <T>(
|
||||
page: Page,
|
||||
query: () => Promise<T>,
|
||||
@@ -37,21 +30,14 @@ export const poll = async <T>(
|
||||
) => {
|
||||
let result;
|
||||
const timeout = Date.now() + 10_000;
|
||||
const signal = activePollsAbortController.signal;
|
||||
|
||||
const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
|
||||
while (!terminate(result) && Date.now() < timeout) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
result = await query();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user