test: filtering notifications

This commit is contained in:
Pujit Mehrotra
2024-09-27 16:28:17 -04:00
parent bf3d46d190
commit b67b0ea633
2 changed files with 214 additions and 33 deletions

View File

@@ -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>(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<Notification, 'id'>,
type: NotificationType = NotificationType.UNREAD
) {
const storedNotification = await findById(id, type);
expect(storedNotification).toBeDefined();
return !!storedNotification;
function doesExist(expectImplementation: typeof expect) {
return async (
{ id }: Pick<Notification, 'id'>,
type: NotificationType = NotificationType.UNREAD
) => {
const storedNotification = await findById(id, type);
expectImplementation(storedNotification).toBeDefined();
return !!storedNotification;
};
}
async function forEachImportance(action: (importance: Importance) => Promise<void>) {
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<void>
) {
@@ -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
});
});

View File

@@ -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
*------------------------------------------------------------------------**/