From f57c8b2b69242c2c27712e220384259338b1aa2c Mon Sep 17 00:00:00 2001 From: izzy Date: Fri, 7 Nov 2025 12:24:02 +0000 Subject: [PATCH] test: write cli service tests --- .../repositories/maintenance.repository.ts | 22 ++++- server/src/services/cli.service.spec.ts | 80 +++++++++++++++++++ server/src/services/cli.service.ts | 23 +----- server/test/utils.ts | 2 +- 4 files changed, 104 insertions(+), 23 deletions(-) diff --git a/server/src/repositories/maintenance.repository.ts b/server/src/repositories/maintenance.repository.ts index d0c5fe4410..61aef45ea0 100644 --- a/server/src/repositories/maintenance.repository.ts +++ b/server/src/repositories/maintenance.repository.ts @@ -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; - constructor() {} + constructor(private configRepository: ConfigRepository) {} exitApp() { /* eslint-disable unicorn/no-process-exit */ @@ -19,4 +24,19 @@ export class MaintenanceRepository { setCloseFn(fn: () => Promise) { 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(); + }); + }); + } } diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 1140d44601..5501a3955d 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -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(); diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index c03ed21bae..bbf661b9e1 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -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, }); diff --git a/server/test/utils.ts b/server/test/utils.ts index d31cb82e3a..c94e9021f6 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -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),