From e0428b565a3249f71e1859518aa2fe8f5e486d00 Mon Sep 17 00:00:00 2001 From: izzy Date: Wed, 3 Dec 2025 09:47:16 +0000 Subject: [PATCH] test: split api e2e tests and passing --- .../api/specs/database-backups.e2e-spec.ts | 266 ++++++++++++++++++ e2e/src/api/specs/maintenance.e2e-spec.ts | 252 +---------------- e2e/src/utils.ts | 2 +- .../maintenance-worker.controller.ts | 2 +- 4 files changed, 273 insertions(+), 249 deletions(-) create mode 100644 e2e/src/api/specs/database-backups.e2e-spec.ts diff --git a/e2e/src/api/specs/database-backups.e2e-spec.ts b/e2e/src/api/specs/database-backups.e2e-spec.ts new file mode 100644 index 0000000000..241f955127 --- /dev/null +++ b/e2e/src/api/specs/database-backups.e2e-spec.ts @@ -0,0 +1,266 @@ +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.stringMatching(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/)], + }), + ); + }); + }); + + describe('DELETE /:filename', async () => { + it('should delete backup', async () => { + const filename = await utils.createBackup(admin.accessToken); + + const { status } = await request(app) + .delete(`/admin/database-backups/${filename}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + 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: 'restore_database', + }); + }); + }); + + // => 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 () => { + const filename = await utils.createBackup(admin.accessToken); + + 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, + ); + }); + }); +}); diff --git a/e2e/src/api/specs/maintenance.e2e-spec.ts b/e2e/src/api/specs/maintenance.e2e-spec.ts index 6b2617fcda..8e4e154328 100644 --- a/e2e/src/api/specs/maintenance.e2e-spec.ts +++ b/e2e/src/api/specs/maintenance.e2e-spec.ts @@ -1,9 +1,9 @@ -import { LoginResponseDto, ManualJobName } from '@immich/sdk'; +import { LoginResponseDto } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, utils } from 'src/utils'; import request from 'supertest'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; describe('/admin/maintenance', () => { let cookie: string | undefined; @@ -32,6 +32,7 @@ describe('/admin/maintenance', () => { const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' }); expect(status).toBe(200); expect(body).toEqual({ + active: false, action: 'end', }); }); @@ -45,73 +46,12 @@ describe('/admin/maintenance', () => { }); }); - describe('GET /backups/list', async () => { - it('should succeed and be empty', async () => { - const { status, body } = await request(app) - .get('/admin/database-backups/list') - .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 expect - .poll( - async () => { - const { status, body } = await request(app) - .get('/admin/database-backups/list') - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - return body; - }, - { - interval: 500, - timeout: 10_000, - }, - ) - .toEqual( - expect.objectContaining({ - backups: [expect.stringMatching(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/)], - }), - ); - }); - }); - - describe('DELETE /backups/:filename', async () => { - it('should delete backup', async () => { - const filename = await utils.createBackup(admin.accessToken); - - const { status } = await request(app) - .delete(`/admin/database-backups/${filename}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - - const { status: listStatus, body } = await request(app) - .get('/admin/database-backups/list') - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(listStatus).toBe(200); - expect(body).toEqual( - expect.objectContaining({ - backups: [], - }), - ); - }); - }); - // => enter maintenance mode 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); @@ -182,6 +122,7 @@ describe('/admin/maintenance', () => { const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' }); expect(status).toBe(200); expect(body).toEqual({ + active: true, action: 'start', }); }); @@ -255,187 +196,4 @@ describe('/admin/maintenance', () => { .toBeFalsy(); }); }); - - // => action: restore database flow - - describe.sequential('POST /backups/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(); - nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); - }); - - it.sequential('should not work when the server is configured', async () => { - const { status, body } = await request(app).post('/admin/database-backups/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/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({ - action: 'restore_database', - }); - }); - }); - - // => 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 () => { - const filename = await utils.createBackup(admin.accessToken); - - 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({ - 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({ - 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({ - 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, - ); - }); - }); }); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index ffcc3fcf31..d8c20fd413 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -592,7 +592,7 @@ export const utils = { }); return await utils.poll( - () => request(app).get('/admin/database-backups/list').set('Authorization', `Bearer ${accessToken}`), + () => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`), ({ status, body }) => status === 200 && body.backups.length === 1, ({ body }) => body.backups[0], ); diff --git a/server/src/maintenance/maintenance-worker.controller.ts b/server/src/maintenance/maintenance-worker.controller.ts index 5aa9bb630f..4259b1289a 100644 --- a/server/src/maintenance/maintenance-worker.controller.ts +++ b/server/src/maintenance/maintenance-worker.controller.ts @@ -75,7 +75,7 @@ export class MaintenanceWorkerController { void this.service.setAction(dto); } - @Get('admin/database-backups/list') + @Get('admin/database-backups') @MaintenanceRoute() listBackups(): Promise { return this.service.listBackups();