test: write cli service tests

This commit is contained in:
izzy 2025-11-07 12:24:02 +00:00
parent 9d869a88b6
commit f57c8b2b69
No known key found for this signature in database
GPG key ID: 5059F398521BB0F6
4 changed files with 104 additions and 23 deletions

View file

@ -1,11 +1,16 @@
import { Injectable } from '@nestjs/common';
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { Server as SocketIO } from 'socket.io';
import { ExitCode } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
@Injectable()
export class MaintenanceRepository {
private closeFn?: () => Promise<void>;
constructor() {}
constructor(private configRepository: ConfigRepository) {}
exitApp() {
/* eslint-disable unicorn/no-process-exit */
@ -19,4 +24,19 @@ export class MaintenanceRepository {
setCloseFn(fn: () => Promise<void>) {
this.closeFn = fn;
}
sendOneShotAppRestart(state: AppRestartEvent): void {
const server = new SocketIO();
const pubClient = new Redis(this.configRepository.getEnv().redis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, () => {
server.serverSideEmit('AppRestart', state, () => {
pubClient.disconnect();
subClient.disconnect();
});
});
}
}

View file

@ -1,3 +1,5 @@
import { jwtVerify } from 'jose';
import { SystemMetadataKey } from 'src/enum';
import { CliService } from 'src/services/cli.service';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@ -80,6 +82,84 @@ describe(CliService.name, () => {
});
});
describe('disableMaintenanceMode', () => {
it('should not do anything if not in maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(sut.disableMaintenanceMode()).resolves.toEqual({
alreadyDisabled: true
});
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
});
it('should disable maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(sut.disableMaintenanceMode()).resolves.toEqual({
alreadyDisabled: false
});
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: false,
});
expect(mocks.maintenance.sendOneShotAppRestart).toHaveBeenCalledWith({
isMaintenanceMode: false,
});
});
});
describe('enableMaintenanceMode', () => {
it('should not do anything if in maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(sut.enableMaintenanceMode()).resolves.toEqual(expect.objectContaining({
alreadyEnabled: true
}));
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
});
it('should enable maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(sut.enableMaintenanceMode()).resolves.toEqual(expect.objectContaining({
alreadyEnabled: false
}));
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: true,
secret: expect.stringMatching(/^\w{128}$/),
});
expect(mocks.maintenance.sendOneShotAppRestart).toHaveBeenCalledWith({
isMaintenanceMode: true,
});
});
const RE_LOGIN_URL = /https:\/\/my.immich.app\/maintenance\?token=([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)/;
it('should return a valid login URL', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
const result = await sut.enableMaintenanceMode();
expect(result).toEqual(expect.objectContaining({
authUrl: expect.stringMatching(RE_LOGIN_URL),
alreadyEnabled: true,
}));
const token = RE_LOGIN_URL.exec(result.authUrl)![1];
await expect(jwtVerify(token, new TextEncoder().encode("secret"))).resolves.toEqual(
expect.objectContaining({
payload: expect.objectContaining({
username: 'cli-admin',
})
}),
);
});
});
describe('disableOAuthLogin', () => {
it('should disable oauth login', async () => {
await sut.disableOAuthLogin();

View file

@ -1,13 +1,9 @@
import { Injectable } from '@nestjs/common';
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { isAbsolute } from 'node:path';
import { Server } from 'socket.io';
import { SALT_ROUNDS } from 'src/constants';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { SystemMetadataKey } from 'src/enum';
import { AppRestartEvent } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { MaintenanceService } from 'src/services/maintenance.service';
import { getExternalDomain } from 'src/utils/misc';
@ -46,21 +42,6 @@ export class CliService extends BaseService {
await this.updateConfig(config);
}
private sendOneShotAppRestart(state: AppRestartEvent): void {
const server = new Server();
const pubClient = new Redis(this.configRepository.getEnv().redis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, () => {
server.serverSideEmit('AppRestart', state, () => {
pubClient.disconnect();
subClient.disconnect();
});
});
}
async disableMaintenanceMode(): Promise<{ alreadyDisabled: boolean }> {
const currentState = await this.systemMetadataRepository
.get(SystemMetadataKey.MaintenanceMode)
@ -75,7 +56,7 @@ export class CliService extends BaseService {
const state = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
this.sendOneShotAppRestart(state);
this.maintenanceRepository.sendOneShotAppRestart(state);
return {
alreadyDisabled: false,
@ -108,7 +89,7 @@ export class CliService extends BaseService {
secret,
});
this.sendOneShotAppRestart({
this.maintenanceRepository.sendOneShotAppRestart({
isMaintenanceMode: true,
});

View file

@ -297,7 +297,7 @@ export const getMocks = () => {
apiKey: automock(ApiKeyRepository),
library: automock(LibraryRepository, { strict: false }),
machineLearning: automock(MachineLearningRepository, { args: [loggerMock], strict: false }),
maintenance: automock(MaintenanceRepository),
maintenance: automock(MaintenanceRepository, { strict: false }),
map: automock(MapRepository, { args: [undefined, undefined, { setContext: () => {} }] }),
media: newMediaRepositoryMock(),
memory: automock(MemoryRepository),