diff --git a/api/src/unraid-api/config/store-sync.service.spec.ts b/api/src/unraid-api/config/store-sync.service.spec.ts new file mode 100644 index 0000000000..c8b6f577d5 --- /dev/null +++ b/api/src/unraid-api/config/store-sync.service.spec.ts @@ -0,0 +1,87 @@ +import { ConfigService } from '@nestjs/config'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StoreSyncService } from '@app/unraid-api/config/store-sync.service.js'; + +const { subscribe, getState } = vi.hoisted(() => ({ + subscribe: vi.fn(), + getState: vi.fn(), +})); + +vi.mock('@app/store/index.js', () => ({ + store: { + subscribe, + getState, + }, +})); + +describe('StoreSyncService', () => { + let service: StoreSyncService; + let configService: ConfigService; + let setSpy: ReturnType; + let unsubscribe: ReturnType; + let subscriber: (() => void) | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + subscribe.mockReset(); + getState.mockReset(); + + unsubscribe = vi.fn(); + subscriber = undefined; + + subscribe.mockImplementation((callback: () => void) => { + subscriber = callback; + return unsubscribe; + }); + + configService = new ConfigService(); + setSpy = vi.spyOn(configService, 'set'); + + service = new StoreSyncService(configService); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it('debounces sync operations and writes once after rapid updates', () => { + getState.mockReturnValue({ count: 2 }); + + subscriber?.(); + vi.advanceTimersByTime(500); + subscriber?.(); + + expect(setSpy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1000); + + expect(setSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledWith('store', { count: 2 }); + }); + + it('skips writes when serialized state is unchanged', () => { + getState.mockReturnValue({ count: 1 }); + + subscriber?.(); + vi.advanceTimersByTime(1000); + + subscriber?.(); + vi.advanceTimersByTime(1000); + + expect(setSpy).toHaveBeenCalledTimes(1); + }); + + it('unsubscribes and clears timer on module destroy', () => { + getState.mockReturnValue({ count: 1 }); + + subscriber?.(); + service.onModuleDestroy(); + vi.advanceTimersByTime(1000); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(setSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/unraid-api/config/store-sync.service.ts b/api/src/unraid-api/config/store-sync.service.ts index afc168c6b1..bd77cc6195 100644 --- a/api/src/unraid-api/config/store-sync.service.ts +++ b/api/src/unraid-api/config/store-sync.service.ts @@ -5,19 +5,46 @@ import type { Unsubscribe } from '@reduxjs/toolkit'; import { store } from '@app/store/index.js'; +const STORE_SYNC_DEBOUNCE_MS = 1000; + @Injectable() export class StoreSyncService implements OnModuleDestroy { private unsubscribe: Unsubscribe; private logger = new Logger(StoreSyncService.name); + private syncTimer: NodeJS.Timeout | null = null; + private lastSyncedState: string | null = null; constructor(private configService: ConfigService) { this.unsubscribe = store.subscribe(() => { - this.configService.set('store', store.getState()); - this.logger.verbose('Synced store to NestJS Config'); + this.scheduleSync(); }); } + private scheduleSync() { + if (this.syncTimer) { + clearTimeout(this.syncTimer); + } + + this.syncTimer = setTimeout(() => { + this.syncTimer = null; + const state = store.getState(); + const serializedState = JSON.stringify(state); + if (serializedState === this.lastSyncedState) { + return; + } + + this.configService.set('store', state); + this.lastSyncedState = serializedState; + this.logger.verbose('Synced store to NestJS Config'); + }, STORE_SYNC_DEBOUNCE_MS); + } + onModuleDestroy() { + if (this.syncTimer) { + clearTimeout(this.syncTimer); + this.syncTimer = null; + } + this.unsubscribe(); } } diff --git a/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts b/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts index 1c582ddd33..81f675a217 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts @@ -1,5 +1,6 @@ // Unit Test File for NotificationsService: loadNotificationFile +import { ConfigService } from '@nestjs/config'; import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -26,7 +27,6 @@ vi.mock('fs/promises', async () => { // Mock getters.dynamix, Logger, and pubsub vi.mock('@app/store/index.js', () => { - // Create test directory path inside factory function const testNotificationsDir = join(tmpdir(), 'unraid-api-test-notifications'); return { @@ -69,6 +69,19 @@ vi.mock('@nestjs/common', async (importOriginal) => { // Create a temporary test directory path for use in integration tests const testNotificationsDir = join(tmpdir(), 'unraid-api-test-notifications'); +const createNotificationsService = (notificationPath = testNotificationsDir) => { + const configService = new ConfigService({ + store: { + dynamix: { + notify: { + path: notificationPath, + }, + }, + }, + }); + return new NotificationsService(configService); +}; + describe('NotificationsService - loadNotificationFile (minimal mocks)', () => { let service: NotificationsService; let mockReadFile: any; @@ -77,7 +90,7 @@ describe('NotificationsService - loadNotificationFile (minimal mocks)', () => { const fsPromises = await import('fs/promises'); mockReadFile = fsPromises.readFile as any; vi.mocked(mockReadFile).mockClear(); - service = new NotificationsService(); + service = createNotificationsService(); }); it('should load and validate a valid notification file', async () => { @@ -247,7 +260,7 @@ describe('NotificationsService - deleteNotification (integration test)', () => { mkdirSync(join(testNotificationsDir, 'unread'), { recursive: true }); mkdirSync(join(testNotificationsDir, 'archive'), { recursive: true }); - service = new NotificationsService(); + service = createNotificationsService(); }); afterEach(() => { diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.module.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.module.ts index 1bb47758e4..53aed24add 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.module.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js'; @Module({ + imports: [ConfigModule], providers: [NotificationsService], exports: [NotificationsService], }) diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts index 8014821982..260a6dbea2 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts @@ -7,6 +7,7 @@ // archiving, unarchiving, deletion, and legacy CLI compatibility. import type { TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import { existsSync } from 'fs'; import { mkdir } from 'fs/promises'; @@ -48,22 +49,16 @@ describe.sequential('NotificationsService', () => { beforeAll(async () => { await mkdir(basePath, { recursive: true }); - // need to mock the dynamix import bc the file watcher is init'ed in the service constructor - // i.e. before we can mock service.paths() - vi.mock(import('../../../../store/index.js'), async (importOriginal) => { - const mod = await importOriginal(); - return { - ...mod, - getters: { - dynamix: () => ({ - notify: { path: basePath }, - }), - }, - } as typeof mod; - }); - const module: TestingModule = await Test.createTestingModule({ - providers: [NotificationsService], + providers: [ + NotificationsService, + { + provide: ConfigService, + useValue: { + get: vi.fn().mockReturnValue(basePath), + }, + }, + ], }).compile(); service = module.get(NotificationsService); // this might need to be a module.resolve instead of get @@ -496,7 +491,15 @@ describe.concurrent('NotificationsService legacy script compatibility', () => { beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [NotificationsService], + providers: [ + NotificationsService, + { + provide: ConfigService, + useValue: { + get: vi.fn().mockReturnValue(basePath), + }, + }, + ], }).compile(); service = module.get(NotificationsService); diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts index 8daa13a98a..1a6fa5ffd7 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { readdir, readFile, rename, stat, unlink, writeFile } from 'fs/promises'; import { basename, join } from 'path'; @@ -55,11 +56,15 @@ export class NotificationsService { }, }; - constructor() { - this.path = getters.dynamix().notify!.path; + constructor(private readonly configService: ConfigService) { + this.path = this.getConfiguredPath(); void this.getNotificationsWatcher(this.path); } + private getConfiguredPath() { + return this.configService.get('store.dynamix.notify.path', '/tmp/notifications'); + } + /** * Returns the paths to the notification directories. * @@ -69,7 +74,7 @@ export class NotificationsService { * - path to the archived notifications */ public paths(): Record<'basePath' | NotificationType, string> { - const basePath = getters.dynamix().notify!.path; + const basePath = this.getConfiguredPath(); if (this.path !== basePath) { // Recreate the watcher with force = true diff --git a/api/src/unraid-api/plugin/plugin.service.ts b/api/src/unraid-api/plugin/plugin.service.ts index 8d2fbf0764..15ca041d28 100644 --- a/api/src/unraid-api/plugin/plugin.service.ts +++ b/api/src/unraid-api/plugin/plugin.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import type { ApiNestPluginDefinition } from '@app/unraid-api/plugin/plugin.interface.js'; import { pluginLogger } from '@app/core/log.js'; @@ -50,7 +51,7 @@ export class PluginService { }; } catch (error) { PluginService.logger.error(`Plugin from ${pkgName} is invalid: %o`, error as object); - const notificationService = new NotificationsService(); + const notificationService = new NotificationsService(new ConfigService()); const errorMessage = error?.toString?.() ?? (error as Error)?.message ?? ''; await notificationService.createNotification({ title: `Plugin from ${pkgName} is invalid`,