mirror of
https://github.com/immich-app/immich.git
synced 2025-12-23 17:25:11 +03:00
test: backup restore service tests
This commit is contained in:
@@ -1,16 +1,19 @@
|
|||||||
import { UnauthorizedException } from '@nestjs/common';
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
import { SignJWT } from 'jose';
|
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 { MaintenanceEphemeralStateRepository } from 'src/maintenance/maintenance-ephemeral-state.repository';
|
||||||
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
||||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
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, () => {
|
describe(MaintenanceWorkerService.name, () => {
|
||||||
let sut: MaintenanceWorkerService;
|
let sut: MaintenanceWorkerService;
|
||||||
let mocks: ServiceMocks;
|
let mocks: ServiceMocks;
|
||||||
let maintenanceWebsocketRepositoryMock: MaintenanceWebsocketRepository;
|
let maintenanceWebsocketRepositoryMock: AutoMocked<MaintenanceWebsocketRepository>;
|
||||||
let maintenanceEphemeralStateRepositoryMock: MaintenanceEphemeralStateRepository;
|
let maintenanceEphemeralStateRepositoryMock: AutoMocked<MaintenanceEphemeralStateRepository>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks = getMocks();
|
mocks = getMocks();
|
||||||
@@ -132,6 +135,8 @@ describe(MaintenanceWorkerService.name, () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
maintenanceEphemeralStateRepositoryMock.getSecret.mockReturnValue('secret');
|
||||||
|
|
||||||
const jwt = await new SignJWT({ _mockValue: true })
|
const jwt = await new SignJWT({ _mockValue: true })
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
.setIssuedAt()
|
.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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user