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 800764cdd..e07783f1c 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 @@ -1,16 +1,20 @@ import { Test, TestingModule } from '@nestjs/testing'; import { NotificationsService } from './notifications.service'; -import { describe, beforeEach, it, expect, vi, beforeAll, afterAll } from 'vitest'; -import { mkdir, rm } from 'fs/promises'; +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; import { existsSync } from 'fs'; import { Importance, type NotificationData, NotificationType, type Notification, + type NotificationOverview, + type NotificationCounts, } from '@app/graphql/generated/api/types'; -describe('NotificationsService', () => { +// we run sequentially here because this module's state depends on external, shared systems +// rn, it's complicated to make the tests atomic & isolated +describe.sequential('NotificationsService', () => { + const notificationImportance = Object.values(Importance); let service: NotificationsService; const basePath = '/tmp/test/notifications'; const testPaths = { @@ -31,18 +35,12 @@ describe('NotificationsService', () => { service = module.get(NotificationsService); // this might need to be a module.resolve instead of get vi.spyOn(service, 'paths').mockImplementation(() => testPaths); - // clear the notifications directory - await mkdir(testPaths.basePath, { recursive: true }); - await rm(testPaths.basePath, { force: true, recursive: true }); - - // init notification directories - await mkdir(testPaths.UNREAD, { recursive: true }); - await mkdir(testPaths.ARCHIVE, { recursive: true }); + await service.deleteAllNotifications(); }); - afterAll(async () => { - // clear notifications directory - await rm(testPaths.basePath, { force: true, recursive: true }); + // make sure each test is isolated (as much as possible) + afterEach(async () => { + await service.deleteAllNotifications(); }); /**------------------------------------------------------------------------ @@ -65,17 +63,19 @@ describe('NotificationsService', () => { ); } - async function doesExist( - { id }: Pick, - type: NotificationType = NotificationType.UNREAD - ) { - const storedNotification = await findById(id, type); - expect(storedNotification).toBeDefined(); - return !!storedNotification; + function doesExist(expectImplementation: typeof expect) { + return async ( + { id }: Pick, + type: NotificationType = NotificationType.UNREAD + ) => { + const storedNotification = await findById(id, type); + expectImplementation(storedNotification).toBeDefined(); + return !!storedNotification; + }; } async function forEachImportance(action: (importance: Importance) => Promise) { - for (const importance of Object.values(Importance)) { + for (const importance of notificationImportance) { await action(importance); } } @@ -86,6 +86,8 @@ describe('NotificationsService', () => { } } + // currently unused b/c of difficulty implementing NotificationOverview tests + // eslint-disable-next-line @typescript-eslint/no-unused-vars async function forAllTypesAndImportances( action: (type: NotificationType, importance: Importance) => Promise ) { @@ -96,17 +98,65 @@ describe('NotificationsService', () => { }); } + function diffCounts(current: NotificationCounts, previous: NotificationCounts) { + return Object.fromEntries( + Object.entries(current).map(([key]) => { + return [key, current[key] - previous[key]] as const; + }) + ); + } + + // currently unused b/c of difficulty implementing NotificationOverview tests + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function diffOverview(current: NotificationOverview, previous: NotificationOverview) { + return Object.fromEntries( + Object.entries(current).map(([key]) => { + return [key, diffCounts(current[key], previous[key])]; + }) + ); + } + /**------------------------------------------------------------------------ * Sanity Tests *------------------------------------------------------------------------**/ - it('NotificationsService test setup should be defined', () => { + it('NotificationsService test setup should be defined', ({ expect }) => { expect(service).toBeDefined(); expect(service.paths()).toEqual(testPaths); + const snapshot = service.getOverview(); Object.values(testPaths).forEach((path) => expect(existsSync(path)).toBeTruthy()); + + const endSnapshot = service.getOverview(); + expect(snapshot).toEqual(endSnapshot); + + // check that all counts are 0 + Object.values(snapshot.archive).forEach((count) => { + expect(count).toEqual(0); + }); + Object.values(snapshot.unread).forEach((count) => { + expect(count).toEqual(0); + }); }); - it('can correctly create, load, and delete a notification', async () => { + it('generates unique ids', async () => { + const notifications = await Promise.all([...new Array(100)].map(() => createNotification())); + const notificationIds = new Set(notifications.map((notification) => notification.id)); + expect(notificationIds.size).toEqual(notifications.length); + }); + + it('returns ISO timestamps', async () => { + const isISODate = (date: string) => new Date(date).toISOString() === date; + const created = await createNotification(); + const loaded = await findById(created.id); + expect(isISODate(created.timestamp ?? '')).toBeTruthy(); + expect(isISODate(loaded?.timestamp ?? '')).toBeTruthy(); + }); + + /**======================================================================== + * CRUD Smoke Tests + *========================================================================**/ + + it('can correctly create, load, and delete a notification', async ({ expect }) => { const notificationData: NotificationData = { title: 'Test Notification', subject: 'Test Subject', @@ -115,7 +165,7 @@ describe('NotificationsService', () => { }; const notification = await createNotification(notificationData); - // data in returned notification matches? + // data in returned notification (from createNotification) matches? Object.entries(notificationData).forEach(([key, value]) => { expect(notification[key]).toEqual(value); }); @@ -123,10 +173,7 @@ describe('NotificationsService', () => { // data in stored notification matches? const storedNotification = await findById(notification.id); expect(storedNotification).toBeDefined(); - if (!storedNotification) { - return; - } - + if (!storedNotification) return; // stop the test if there's no stored notification expect(storedNotification.id).toEqual(notification.id); expect(storedNotification.timestamp).toEqual(notification.timestamp); Object.entries(notificationData).forEach(([key, value]) => { @@ -139,20 +186,24 @@ describe('NotificationsService', () => { expect(deleted).toBeUndefined(); }); - it('can correctly archive and unarchive a notification', async () => { + /**-------------------------------------------- + * CRUD: Update Tests + *---------------------------------------------**/ + + it('can correctly archive and unarchive a notification', async ({ expect }) => { await forEachImportance(async (importance) => { const notification = await createNotification({ importance }); await service.archiveNotification(notification); - let exists = await doesExist(notification, NotificationType.ARCHIVE); + let exists = await doesExist(expect)(notification, NotificationType.ARCHIVE); if (!exists) return; await service.markAsUnread(notification); - exists = await doesExist(notification, NotificationType.UNREAD); + exists = await doesExist(expect)(notification, NotificationType.UNREAD); }); }); - it('can archive & unarchive all', async () => { + it('can archive & unarchive all', async ({ expect }) => { await forEachImportance(async (importance) => { const notifications = await Promise.all([ createNotification({ importance }), @@ -169,4 +220,103 @@ describe('NotificationsService', () => { await service.unarchiveAll(importance); }); }); + + /**------------------------------------------------------------------------ + * Filtering Tests + *------------------------------------------------------------------------**/ + + it.each(notificationImportance)('loadNotifications respects %s filter', async (importance) => { + const notifications = await Promise.all([ + createNotification({ importance: Importance.ALERT }), + createNotification({ importance: Importance.ALERT }), + createNotification({ importance: Importance.ALERT }), + createNotification({ importance: Importance.INFO }), + createNotification({ importance: Importance.INFO }), + createNotification({ importance: Importance.INFO }), + createNotification({ importance: Importance.WARNING }), + createNotification({ importance: Importance.WARNING }), + createNotification({ importance: Importance.WARNING }), + ]); + expect(notifications.length).toEqual(9); + + const loaded = await service.getNotifications({ + type: NotificationType.UNREAD, + importance, + limit: 50, + offset: 0, + }); + expect(loaded.length).toEqual(3); + }); + + it.each(notificationImportance)('archiveAll respects %s filter', async (importance) => { + const notifications = await Promise.all([ + createNotification({ importance: Importance.ALERT }), + createNotification({ importance: Importance.ALERT }), + createNotification({ importance: Importance.ALERT }), + createNotification({ importance: Importance.INFO }), + createNotification({ importance: Importance.INFO }), + createNotification({ importance: Importance.INFO }), + createNotification({ importance: Importance.WARNING }), + createNotification({ importance: Importance.WARNING }), + createNotification({ importance: Importance.WARNING }), + ]); + expect(notifications.length).toEqual(9); + + await service.archiveAll(importance); + const unreads = await service.getNotifications({ + type: NotificationType.UNREAD, + importance, + limit: 50, + offset: 0, + }); + const archives = await service.getNotifications({ + type: NotificationType.ARCHIVE, + importance, + limit: 50, + offset: 0, + }); + expect(unreads.length).toEqual(0); + expect(archives.length).toEqual(3); + }); + + it.each(notificationImportance)('unarchiveAll respects %s filter', async (importance) => { + const notifications = await Promise.all([ + createNotification({ importance: Importance.ALERT }), + createNotification({ importance: Importance.ALERT }), + createNotification({ importance: Importance.ALERT }), + createNotification({ importance: Importance.INFO }), + createNotification({ importance: Importance.INFO }), + createNotification({ importance: Importance.INFO }), + createNotification({ importance: Importance.WARNING }), + createNotification({ importance: Importance.WARNING }), + createNotification({ importance: Importance.WARNING }), + ]); + expect(notifications.length).toEqual(9); + + // test unarchive + await service.archiveAll(); + await service.unarchiveAll(importance); + const unreads = await service.getNotifications({ + type: NotificationType.UNREAD, + importance, + limit: 50, + offset: 0, + }); + const archives = await service.getNotifications({ + type: NotificationType.ARCHIVE, + importance, + limit: 50, + offset: 0, + }); + expect(unreads.length).toEqual(3); + expect(archives.length).toEqual(0); + }); + + /**======================================================================== + * Overview (Notification Stats) State + *========================================================================**/ + + it.skip('calculates stats correctly', async () => { + // todo implement + }); }); 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 b17b00c49..7e67fb3ea 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts @@ -12,7 +12,7 @@ import { } from '@app/graphql/generated/api/types'; import { getters } from '@app/store'; import { Injectable } from '@nestjs/common'; -import { readdir, rename, unlink, writeFile } from 'fs/promises'; +import { mkdir, readdir, rename, rm, unlink, writeFile } from 'fs/promises'; import { basename, join } from 'path'; import { Logger } from '@nestjs/common'; import { isFulfilled, isRejected, unraidTimestamp } from '@app/utils'; @@ -227,6 +227,37 @@ export class NotificationsService { return { notification, overview: NotificationsService.overview }; } + /** + * Deletes all notifications from disk, but preserves + * notification directories. + * + * Resets the notification overview to all zeroes. + */ + public async deleteAllNotifications() { + const { basePath, UNREAD, ARCHIVE } = this.paths(); + // ensure the directory exists before deleting + await mkdir(basePath, { recursive: true }); + await rm(basePath, { force: true, recursive: true }); + // recreate each notification directory + await mkdir(UNREAD, { recursive: true }); + await mkdir(ARCHIVE, { recursive: true }); + NotificationsService.overview = { + unread: { + alert: 0, + info: 0, + warning: 0, + total: 0, + }, + archive: { + alert: 0, + info: 0, + warning: 0, + total: 0, + }, + }; + return this.getOverview(); + } + /**------------------------------------------------------------------------ * CRUD: Updating Notifications *------------------------------------------------------------------------**/