From 31410c3c20f9be10a5ebb821baf5a41edf63eba1 Mon Sep 17 00:00:00 2001 From: izzy Date: Thu, 20 Nov 2025 15:24:56 +0000 Subject: [PATCH] test: backup restore service tests --- .../maintenance-worker.service.spec.ts | 158 +++++++++++++++++- 1 file changed, 154 insertions(+), 4 deletions(-) diff --git a/server/src/maintenance/maintenance-worker.service.spec.ts b/server/src/maintenance/maintenance-worker.service.spec.ts index 6b43a0730c..20add8eea8 100644 --- a/server/src/maintenance/maintenance-worker.service.spec.ts +++ b/server/src/maintenance/maintenance-worker.service.spec.ts @@ -1,16 +1,19 @@ import { UnauthorizedException } from '@nestjs/common'; import { SignJWT } from 'jose'; -import { MaintenanceAction, SystemMetadataKey } from 'src/enum'; +import { DateTime } from 'luxon'; +import { PassThrough, Readable } from 'node:stream'; +import { StorageCore } from 'src/cores/storage.core'; +import { MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum'; import { MaintenanceEphemeralStateRepository } from 'src/maintenance/maintenance-ephemeral-state.repository'; import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository'; import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service'; -import { automock, getMocks, ServiceMocks } from 'test/utils'; +import { automock, AutoMocked, getMocks, mockDuplex, mockSpawn, ServiceMocks } from 'test/utils'; describe(MaintenanceWorkerService.name, () => { let sut: MaintenanceWorkerService; let mocks: ServiceMocks; - let maintenanceWebsocketRepositoryMock: MaintenanceWebsocketRepository; - let maintenanceEphemeralStateRepositoryMock: MaintenanceEphemeralStateRepository; + let maintenanceWebsocketRepositoryMock: AutoMocked; + let maintenanceEphemeralStateRepositoryMock: AutoMocked; beforeEach(() => { mocks = getMocks(); @@ -132,6 +135,8 @@ describe(MaintenanceWorkerService.name, () => { }, }); + maintenanceEphemeralStateRepositoryMock.getSecret.mockReturnValue('secret'); + const jwt = await new SignJWT({ _mockValue: true }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() @@ -164,4 +169,149 @@ describe(MaintenanceWorkerService.name, () => { }); }); }); + + /** + * Actions + */ + + describe('action: start', () => { + it('should not do anything', async () => { + await sut.runAction({ + action: MaintenanceAction.Start, + }); + + expect(mocks.logger.log).toHaveBeenCalledTimes(0); + }); + }); + + describe('action: restore database', () => { + beforeEach(() => { + function* mockData() { + yield ''; + } + + mocks.database.tryLock.mockResolvedValueOnce(true); + + mocks.storage.readdir.mockResolvedValue([]); + mocks.process.spawn.mockReturnValue(mockSpawn(0, 'data', '')); + mocks.process.createSpawnDuplexStream.mockImplementation(() => mockDuplex('command', 0, 'data', '')); + mocks.storage.rename.mockResolvedValue(); + mocks.storage.unlink.mockResolvedValue(); + mocks.storage.createPlainReadStream.mockReturnValue(Readable.from(mockData())); + mocks.storage.createWriteStream.mockReturnValue(new PassThrough()); + mocks.storage.createGzip.mockReturnValue(new PassThrough()); + mocks.storage.createGunzip.mockReturnValue(new PassThrough()); + }); + + it('should update maintenance mode state', async () => { + maintenanceEphemeralStateRepositoryMock.getSecret.mockReturnValue('secret'); + + await sut.runAction({ + action: MaintenanceAction.RestoreDatabase, + restoreBackupFilename: 'filename', + }); + + expect(mocks.database.tryLock).toHaveBeenCalled(); + expect(mocks.logger.log).toHaveBeenCalledWith('Running maintenance action restore_database'); + + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, { + isMaintenanceMode: true, + secret: 'secret', + action: { + action: 'start', + }, + }); + }); + + it('should fail to restore invalid backup', async () => { + await sut.runAction({ + action: MaintenanceAction.RestoreDatabase, + restoreBackupFilename: 'filename', + }); + + expect(maintenanceEphemeralStateRepositoryMock.setStatus).toHaveBeenCalledWith({ + action: MaintenanceAction.RestoreDatabase, + error: 'Error: Invalid backup file format!', + }); + }); + + it('should successfully run a backup', async () => { + await sut.runAction({ + action: MaintenanceAction.RestoreDatabase, + restoreBackupFilename: 'development-filename', + }); + + expect(maintenanceEphemeralStateRepositoryMock.setStatus).toHaveBeenCalledWith({ + action: MaintenanceAction.RestoreDatabase, + progress: expect.any(Number), + }); + + expect(maintenanceEphemeralStateRepositoryMock.setStatus).toHaveBeenLastCalledWith({ + action: 'end', + }); + }); + + it('should fail if backup creation fails', async () => { + mocks.process.createSpawnDuplexStream.mockReturnValueOnce(mockDuplex('pg_dump', 1, '', 'error')); + + await sut.runAction({ + action: MaintenanceAction.RestoreDatabase, + restoreBackupFilename: 'development-filename', + }); + + expect(maintenanceEphemeralStateRepositoryMock.setStatus).toHaveBeenLastCalledWith({ + action: MaintenanceAction.RestoreDatabase, + error: 'Error: pg_dump non-zero exit code (1)\nerror', + }); + }); + + it('should fail if restore itself fails', async () => { + mocks.process.createSpawnDuplexStream + .mockReturnValueOnce(mockDuplex('pg_dump', 0, 'data', '')) + .mockReturnValueOnce(mockDuplex('gzip', 0, 'data', '')) + .mockReturnValueOnce(mockDuplex('psql', 1, '', 'error')); + + await sut.runAction({ + action: MaintenanceAction.RestoreDatabase, + restoreBackupFilename: 'development-filename', + }); + + expect(maintenanceEphemeralStateRepositoryMock.setStatus).toHaveBeenLastCalledWith({ + action: MaintenanceAction.RestoreDatabase, + error: 'Error: psql non-zero exit code (1)\nerror', + }); + }); + }); + + /** + * Backups + */ + + describe('listBackups', () => { + it('should give us all valid and failed backups', async () => { + mocks.storage.readdir.mockResolvedValue([ + `immich-db-backup-${DateTime.fromISO('2025-07-25T11:02:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz.tmp`, + `immich-db-backup-${DateTime.fromISO('2025-07-27T11:01:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz`, + 'immich-db-backup-1753789649000.sql.gz', + `immich-db-backup-${DateTime.fromISO('2025-07-29T11:01:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz`, + ]); + + await expect(sut.listBackups()).resolves.toMatchObject({ + backups: [ + 'immich-db-backup-20250729T110116-v1.234.5-pg14.5.sql.gz', + 'immich-db-backup-20250727T110116-v1.234.5-pg14.5.sql.gz', + 'immich-db-backup-1753789649000.sql.gz', + ], + failedBackups: ['immich-db-backup-20250725T110216-v1.234.5-pg14.5.sql.gz.tmp'], + }); + }); + }); + + describe('deleteBackup', () => { + it('should unlink the target file', async () => { + await sut.deleteBackup('filename'); + expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); + expect(mocks.storage.unlink).toHaveBeenCalledWith(`${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename`); + }); + }); });