Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions api/src/unraid-api/config/store-sync.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;
let unsubscribe: ReturnType<typeof vi.fn>;
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();
});
});
31 changes: 29 additions & 2 deletions api/src/unraid-api/config/store-sync.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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],
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>(NotificationsService); // this might need to be a module.resolve instead of get
Expand Down Expand Up @@ -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>(NotificationsService);
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -55,11 +56,15 @@ export class NotificationsService {
},
};

constructor() {
this.path = getters.dynamix().notify!.path;
constructor(private readonly configService: ConfigService) {
this.path = this.getConfiguredPath();
Comment on lines +59 to +60

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep NotificationsService constructible outside Nest DI

Making ConfigService a required constructor dependency breaks existing non-DI instantiation paths: PluginService.importPlugins still calls new NotificationsService() when plugin loading fails, so this.configService is undefined and getConfiguredPath() throws before createNotification runs. In that failure path, the original plugin-validation error gets masked and the alert notification is never created, which regresses diagnostics for invalid plugins.

Useful? React with 👍 / 👎.

void this.getNotificationsWatcher(this.path);
}

private getConfiguredPath() {
return this.configService.get<string>('store.dynamix.notify.path', '/tmp/notifications');
}

/**
* Returns the paths to the notification directories.
*
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion api/src/unraid-api/plugin/plugin.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential issue: manually instantiated ConfigService lacks configuration context.

Creating new ConfigService() directly bypasses NestJS dependency injection, meaning this ConfigService instance won't have access to the actual application configuration. The notification will use the default path (/tmp/notifications) rather than any configured path.

Since this is in a static method (importPlugins), proper DI is challenging. Consider one of these approaches:

  1. Accept the default path behavior for error notifications (simplest, may be acceptable)
  2. Pass ConfigService into the static method from a properly injected context
  3. Use a module-level singleton or lazy initialization pattern

If the default path is acceptable for plugin error notifications, consider adding a comment explaining this intentional behavior.

💡 Optional: Add clarifying comment
-                const notificationService = new NotificationsService(new ConfigService());
+                // Note: Uses default config path since we're outside DI context during plugin load
+                const notificationService = new NotificationsService(new ConfigService());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/src/unraid-api/plugin/plugin.service.ts` at line 54, The static
importPlugins method instantiates ConfigService directly which bypasses NestJS
DI so NotificationsService(new ConfigService()) will use defaults (e.g.,
/tmp/notifications); fix by either (A) changing importPlugins signature to
accept a properly injected ConfigService and pass that into
NotificationsService, or (B) replace the direct new with a module-level
singleton/lazy getter that is initialized from the DI context at startup, or (C)
if using the default path is intentional, add a clear comment in importPlugins
next to NotificationsService(new ConfigService()) stating this is deliberate and
that the notification path will fall back to the default.

const errorMessage = error?.toString?.() ?? (error as Error)?.message ?? '';
await notificationService.createNotification({
title: `Plugin from ${pkgName} is invalid`,
Expand Down
Loading