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
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,33 @@ import {
} from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';

const { mockWatch } = vi.hoisted(() => {
const watcher = {
on: vi.fn().mockReturnThis(),
close: vi.fn().mockResolvedValue(undefined),
};

return {
mockWatch: vi.fn(() => watcher),
};
});

// Mock fs/promises for unit tests
vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises');
const mockReadFile = vi.fn();
const mockStat = vi.fn(actual.stat);
return {
...actual,
readFile: mockReadFile,
stat: mockStat,
};
});

vi.mock('chokidar', () => ({
watch: mockWatch,
}));

// Mock getters.dynamix, Logger, and pubsub
vi.mock('@app/store/index.js', () => {
const testNotificationsDir = join(tmpdir(), 'unraid-api-test-notifications');
Expand Down Expand Up @@ -84,13 +101,80 @@ const createNotificationsService = (notificationPath = testNotificationsDir) =>

describe('NotificationsService - loadNotificationFile (minimal mocks)', () => {
let service: NotificationsService;
let mockReadFile: any;
let mockReadFile: typeof import('fs/promises').readFile;
let mockStat: typeof import('fs/promises').stat;

beforeEach(async () => {
const fsPromises = await import('fs/promises');
mockReadFile = fsPromises.readFile as any;
mockReadFile = fsPromises.readFile;
mockStat = fsPromises.stat;
vi.mocked(mockReadFile).mockClear();
vi.mocked(mockStat).mockClear();
mockWatch.mockClear();
Reflect.set(NotificationsService, 'watcher', null);
service = createNotificationsService();
await Reflect.get(service, 'initialization');
});

afterEach(() => {
Reflect.set(NotificationsService, 'watcher', null);
});

it('creates the notifications watcher without replaying existing files', () => {
expect(mockWatch).toHaveBeenCalledWith(
testNotificationsDir,
expect.objectContaining({
ignoreInitial: true,
})
);
});

it('replays buffered add events after overview hydration', async () => {
const bufferedPath = `${testNotificationsDir}/unread/buffered.notify`;
const hydratedService = createNotificationsService();
const processNotificationAdd = vi.fn().mockResolvedValue(undefined);
const handleNotificationAdd = (
Reflect.get(hydratedService, 'handleNotificationAdd') as (path: string) => Promise<void>
).bind(hydratedService);

Reflect.set(
hydratedService,
'ensureNotificationDirectories',
vi.fn().mockResolvedValue(undefined)
);
Reflect.set(hydratedService, 'publishOverview', vi.fn().mockResolvedValue(undefined));
Reflect.set(hydratedService, 'processNotificationAdd', processNotificationAdd);
Reflect.set(
hydratedService,
'getNotificationsWatcher',
vi.fn().mockImplementation(async () => {
await handleNotificationAdd(bufferedPath);
return {
close: vi.fn().mockResolvedValue(undefined),
on: vi.fn().mockReturnThis(),
};
})
);
Reflect.set(
hydratedService,
'buildOverviewSnapshot',
vi.fn().mockResolvedValue({
errorOccurred: false,
overview: {
unread: { alert: 0, info: 0, warning: 0, total: 0 },
archive: { alert: 0, info: 0, warning: 0, total: 0 },
},
seenPaths: new Set<string>(),
})
);

await Reflect.get(hydratedService, 'initializeNotificationsState').call(
hydratedService,
testNotificationsDir,
true
);

expect(processNotificationAdd).toHaveBeenCalledWith(bufferedPath);
});

it('should load and validate a valid notification file', async () => {
Expand Down Expand Up @@ -244,12 +328,51 @@ importance=alert`;
expect(result.timestamp).toBeUndefined(); // Malformed timestamp results in undefined
expect(result.formattedTimestamp).toBe('not-a-timestamp'); // Returns original string when parsing fails
});

it('limits concurrent notification file reads', async () => {
const fileCount = 96;
const files = Array.from({ length: fileCount }, (_, index) => `/test/path/${index}.notify`);
let activeReads = 0;
let maxConcurrentReads = 0;

vi.mocked(mockReadFile).mockImplementation(async () => {
activeReads += 1;
maxConcurrentReads = Math.max(maxConcurrentReads, activeReads);
await new Promise((resolve) => setTimeout(resolve, 5));
activeReads -= 1;
return `timestamp=1609459200
event=Test Event
subject=Test Subject
description=Test Description
importance=alert`;
});

const [notifications] = await Reflect.get(service, 'loadNotificationsFromPaths').call(
service,
files,
{}
);

expect(notifications).toHaveLength(fileCount);
expect(maxConcurrentReads).toBeLessThanOrEqual(32);
});

it('surfaces stat failures when listing notification files', async () => {
const unreadPath = join(testNotificationsDir, 'unread');
const filePath = join(unreadPath, 'stat-failure.notify');
writeFileSync(filePath, 'timestamp=1609459200');
vi.mocked(mockStat).mockRejectedValueOnce(new Error('stat failed'));

await expect(
Reflect.get(service, 'listFilesInFolder').call(service, unreadPath)
).rejects.toThrow();
});
});

describe('NotificationsService - deleteNotification (integration test)', () => {
let service: NotificationsService;

beforeEach(() => {
beforeEach(async () => {
// Clean up any existing test directory
if (existsSync(testNotificationsDir)) {
rmSync(testNotificationsDir, { recursive: true, force: true });
Expand All @@ -261,6 +384,7 @@ describe('NotificationsService - deleteNotification (integration test)', () => {
mkdirSync(join(testNotificationsDir, 'archive'), { recursive: true });

service = createNotificationsService();
await Reflect.get(service, 'initialization');
});

afterEach(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,29 @@ import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils
// defined outside `describe` so it's defined inside the `beforeAll`
// needed to mock the dynamix import
const basePath = '/tmp/test/notifications';
const zeroOverview = (): NotificationOverview => ({
unread: {
alert: 0,
info: 0,
warning: 0,
total: 0,
},
archive: {
alert: 0,
info: 0,
warning: 0,
total: 0,
},
});

async function disableNotificationsWatcher() {
const watcher = Reflect.get(NotificationsService, 'watcher') as {
close?: () => Promise<void>;
} | null;
await watcher?.close?.();
Reflect.set(NotificationsService, 'watcher', null);
Reflect.set(NotificationsService, 'overview', zeroOverview());
}

// we run sequentially here because this module's state depends on external, shared systems
// rn, it's complicated to make the tests atomic & isolated
Expand Down Expand Up @@ -62,13 +85,15 @@ describe.sequential('NotificationsService', () => {
}).compile();

service = module.get<NotificationsService>(NotificationsService); // this might need to be a module.resolve instead of get
await disableNotificationsWatcher();
vi.spyOn(service, 'paths').mockImplementation(() => testPaths);

await service.deleteAllNotifications();
});

// make sure each test is isolated (as much as possible)
afterEach(async () => {
Reflect.set(NotificationsService, 'overview', zeroOverview());
await service.deleteAllNotifications();
});

Expand Down Expand Up @@ -503,6 +528,7 @@ describe.concurrent('NotificationsService legacy script compatibility', () => {
}).compile();

service = module.get<NotificationsService>(NotificationsService);
await disableNotificationsWatcher();
});

it.for([['normal'], ['warning'], ['alert']] as const)(
Expand Down
Loading
Loading