Files
TREK/server/tests/unit/nest/notifications.inapp-prefs.test.ts
T
Maurice ac96644508 refactor(server): fold the notifications domain out of services/
Four files taken apart rather than moved, because they only worked as a knot.

The mailer becomes its own leaf module. AuthService sends the password-reset
mail while NotificationsModule imports AuthModule for the demo gate in
NotificationsMcp - a mailer inside the notifications domain would have closed a
hard cycle that only a forwardRef could open again. Webhook and ntfy stay with
notifications; auth has no business importing them.

The channel registry stays a module singleton and does NOT become a provider.
PluginRuntimeService pushes its channel getter in at onModuleInit, and
notifications.bridge.ts builds its own NotificationsService for the scheduler
crons and the memories senders. A provider would hand the bridge a second, empty
registry - every plugin channel would then go quiet with no error anywhere.
CHOVR-015 drives that seam end to end: a channel published through the runtime,
delivered through the bridge. Nothing pinned it before; the existing cases all
set the source themselves.

The built-ins no longer self-register by side-effect import. They used to exist
only because notificationPreferencesService happened to `import
'./notifications/builtins'`; removing that line would have silenced email,
webhook and ntfy without a failure. They are built from the injected transports
and registered from NotificationsService's constructor, so both instances have
them.

No cached nodemailer transport: MailerService.createTransport re-reads the SMTP
config and skipTlsVerify per send, exactly as the three inline call sites did,
so an admin changing them does not have to restart.

Preferences become a provider over DatabaseService. Eight places built
NotificationsService by hand, so a tests/helpers/notifications.ts factory keeps
the next constructor parameter a one-file diff instead of an eight-file one.
2026-08-08 12:09:56 +02:00

402 lines
17 KiB
TypeScript

/**
* Unit tests for in-app notification preference filtering in
* NotificationsService.createNotification() and the respondToBoolean flow —
* INOTIF-001 to INOTIF-004 (moved 1:1 from the legacy
* tests/unit/services/inAppNotificationPrefs.test.ts when the in-app store
* SQL folded into nest/notifications).
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
// Mock WebSocket broadcast — must use vi.hoisted() so broadcastMock is available
// when the vi.mock factory is evaluated (factories are hoisted before const declarations)
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
// RealtimeService imports both names from src/websocket, so the mock must
// export both even though only broadcastToUser is asserted here.
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, disableNotificationPref } from '../../helpers/factories';
import { registerAction } from '../../../src/nest/notifications/in-app-actions';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { NotificationsService } from '../../../src/nest/notifications/notifications.service';
import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications';
const notifications = makeNotificationsService(new DatabaseService(testDb));
const createNotification = notifications.createNotification.bind(notifications);
const createNotificationForRecipient = notifications.createNotificationForRecipient.bind(notifications);
const respondToBoolean = notifications.respond.bind(notifications);
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
});
afterAll(() => {
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// createNotification — preference filtering
// ─────────────────────────────────────────────────────────────────────────────
describe('createNotification — preference filtering', () => {
it('INOTIF-001 — notification without event_type is delivered to all recipients (backward compat)', () => {
const { user: admin } = createAdmin(testDb);
const { user: recipient } = createUser(testDb);
// The admin scope targets all admins — create a second admin as the sender
const { user: sender } = createAdmin(testDb);
// Send to a specific user (user scope) without event_type
const ids = createNotification({
type: 'simple',
scope: 'user',
target: recipient.id,
sender_id: sender.id,
title_key: 'notifications.test.title',
text_key: 'notifications.test.text',
// no event_type
});
expect(ids.length).toBe(1);
const row = testDb.prepare('SELECT * FROM notifications WHERE recipient_id = ?').get(recipient.id);
expect(row).toBeDefined();
// Also verify the admin who disabled all prefs still gets messages without event_type
disableNotificationPref(testDb, admin.id, 'trip_invite', 'inapp');
// admin still gets this since no event_type check
const adminIds = createNotification({
type: 'simple',
scope: 'user',
target: admin.id,
sender_id: sender.id,
title_key: 'notifications.test.title',
text_key: 'notifications.test.text',
});
expect(adminIds.length).toBe(1);
});
it('INOTIF-002 — notification with event_type skips recipients who have disabled that event on inapp', () => {
const { user: sender } = createAdmin(testDb);
const { user: recipient1 } = createUser(testDb);
const { user: recipient2 } = createUser(testDb);
// recipient2 has disabled inapp for trip_invite
disableNotificationPref(testDb, recipient2.id, 'trip_invite', 'inapp');
// Use a trip to target both members
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Test Trip', sender.id)).lastInsertRowid as number;
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient1.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient2.id);
const ids = createNotification({
type: 'simple',
scope: 'trip',
target: tripId,
sender_id: sender.id,
event_type: 'trip_invite',
title_key: 'notifications.test.title',
text_key: 'notifications.test.text',
});
// sender excluded, recipient1 included, recipient2 skipped (disabled pref)
expect(ids.length).toBe(1);
const r1 = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient1.id);
const r2 = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient2.id);
expect(r1).toBeDefined();
expect(r2).toBeUndefined();
});
it('INOTIF-003 — notification with event_type delivers to recipients with no stored preferences', () => {
const { user: sender } = createAdmin(testDb);
const { user: recipient } = createUser(testDb);
// No preferences stored for recipient — should default to enabled
const ids = createNotification({
type: 'simple',
scope: 'user',
target: recipient.id,
sender_id: sender.id,
event_type: 'trip_invite',
title_key: 'notifications.test.title',
text_key: 'notifications.test.text',
});
expect(ids.length).toBe(1);
const row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient.id);
expect(row).toBeDefined();
});
it('INOTIF-003b — createNotificationForRecipient inserts a single notification and broadcasts via WS', () => {
const { user: sender } = createAdmin(testDb);
const { user: recipient } = createUser(testDb);
const id = createNotificationForRecipient(
{
type: 'navigate',
scope: 'user',
target: recipient.id,
sender_id: sender.id,
event_type: 'trip_invite',
title_key: 'notif.trip_invite.title',
text_key: 'notif.trip_invite.text',
navigate_text_key: 'notif.action.view_trip',
navigate_target: '/trips/99',
},
recipient.id,
{ username: 'admin', avatar: null }
);
expect(id).toBeTypeOf('number');
const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as { recipient_id: number; navigate_target: string } | undefined;
expect(row).toBeDefined();
expect(row!.recipient_id).toBe(recipient.id);
expect(row!.navigate_target).toBe('/trips/99');
expect(broadcastMock).toHaveBeenCalledTimes(1);
expect(broadcastMock.mock.calls[0][0]).toBe(recipient.id);
});
it('INOTIF-004 — admin-scope version_available only reaches admins with enabled pref', () => {
const { user: admin1 } = createAdmin(testDb);
const { user: admin2 } = createAdmin(testDb);
// admin2 disables version_available inapp notifications
disableNotificationPref(testDb, admin2.id, 'version_available', 'inapp');
const ids = createNotification({
type: 'navigate',
scope: 'admin',
target: 0,
sender_id: null,
event_type: 'version_available',
title_key: 'notifications.versionAvailable.title',
text_key: 'notifications.versionAvailable.text',
navigate_text_key: 'notifications.versionAvailable.button',
navigate_target: '/admin',
});
// Only admin1 should receive it
expect(ids.length).toBe(1);
const admin1Row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(admin1.id);
const admin2Row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(admin2.id);
expect(admin1Row).toBeDefined();
expect(admin2Row).toBeUndefined();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// respondToBoolean
// ─────────────────────────────────────────────────────────────────────────────
function insertBooleanNotification(recipientId: number, senderId: number | null = null): number {
const result = testDb.prepare(`
INSERT INTO notifications (
type, scope, target, sender_id, recipient_id,
title_key, title_params, text_key, text_params,
positive_text_key, negative_text_key, positive_callback, negative_callback
) VALUES ('boolean', 'user', ?, ?, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
'notif.action.accept', 'notif.action.decline',
'{"action":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}'
)
`).run(recipientId, senderId, recipientId);
return result.lastInsertRowid as number;
}
function insertSimpleNotification(recipientId: number): number {
const result = testDb.prepare(`
INSERT INTO notifications (
type, scope, target, sender_id, recipient_id,
title_key, title_params, text_key, text_params
) VALUES ('simple', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}')
`).run(recipientId, recipientId);
return result.lastInsertRowid as number;
}
describe('respondToBoolean', () => {
it('INOTIF-005 — positive response sets response=positive, marks read, broadcasts update', async () => {
const { user } = createUser(testDb);
const id = insertBooleanNotification(user.id);
const result = await respondToBoolean(id, user.id, 'positive');
expect(result.success).toBe(true);
expect(result.notification).toBeDefined();
const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as any;
expect(row.response).toBe('positive');
expect(row.is_read).toBe(1);
expect(broadcastMock).toHaveBeenCalledWith(user.id, expect.objectContaining({ type: 'notification:updated' }));
});
it('INOTIF-006 — negative response sets response=negative', async () => {
const { user } = createUser(testDb);
const id = insertBooleanNotification(user.id);
const result = await respondToBoolean(id, user.id, 'negative');
expect(result.success).toBe(true);
const row = testDb.prepare('SELECT response FROM notifications WHERE id = ?').get(id) as any;
expect(row.response).toBe('negative');
});
it('INOTIF-007 — double-response prevention returns error on second call', async () => {
const { user } = createUser(testDb);
const id = insertBooleanNotification(user.id);
await respondToBoolean(id, user.id, 'positive');
const result = await respondToBoolean(id, user.id, 'negative');
expect(result.success).toBe(false);
expect(result.error).toMatch(/already responded/i);
});
it('INOTIF-008 — response on a simple notification returns error', async () => {
const { user } = createUser(testDb);
const id = insertSimpleNotification(user.id);
const result = await respondToBoolean(id, user.id, 'positive');
expect(result.success).toBe(false);
expect(result.error).toMatch(/not a boolean/i);
});
it('INOTIF-009 — response on a non-existent notification returns error', async () => {
const { user } = createUser(testDb);
const result = await respondToBoolean(99999, user.id, 'positive');
expect(result.success).toBe(false);
expect(result.error).toMatch(/not found/i);
});
it('INOTIF-010 — response on notification belonging to another user returns error', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const id = insertBooleanNotification(owner.id);
const result = await respondToBoolean(id, other.id, 'positive');
expect(result.success).toBe(false);
expect(result.error).toMatch(/not found/i);
});
// ── Regression pins from the post-fold fix(server) commit ──────────────────
it('INOTIF-011 — a concurrent double-submit executes the action handler exactly once (CAS before handler)', async () => {
const { user } = createUser(testDb);
const calls: string[] = [];
// Slow handler: with the legacy handler-before-CAS order, both submits
// passed the response-IS-NULL pre-check while the first awaited here and
// the action ran twice.
registerAction('slow_approve', async () => {
calls.push('run');
await new Promise((resolve) => setImmediate(resolve));
});
const id = testDb.prepare(`
INSERT INTO notifications (
type, scope, target, sender_id, recipient_id,
title_key, title_params, text_key, text_params,
positive_text_key, negative_text_key, positive_callback, negative_callback
) VALUES ('boolean', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
'notif.action.accept', 'notif.action.decline',
'{"action":"slow_approve","payload":{}}', '{"action":"slow_approve","payload":{}}'
)
`).run(user.id, user.id).lastInsertRowid as number;
const [first, second] = await Promise.all([
respondToBoolean(id, user.id, 'positive'),
respondToBoolean(id, user.id, 'negative'),
]);
expect(calls).toHaveLength(1);
const outcomes = [first, second];
expect(outcomes.filter((r) => r.success)).toHaveLength(1);
expect(outcomes.find((r) => !r.success)?.error).toMatch(/already responded/i);
});
it('INOTIF-012 — a handler failure releases the claim: error returned, notification stays unresponded, retry succeeds', async () => {
const { user } = createUser(testDb);
let shouldFail = true;
registerAction('flaky_approve', async () => {
if (shouldFail) throw new Error('downstream unavailable');
});
const id = testDb.prepare(`
INSERT INTO notifications (
type, scope, target, sender_id, recipient_id,
title_key, title_params, text_key, text_params,
positive_text_key, negative_text_key, positive_callback, negative_callback
) VALUES ('boolean', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
'notif.action.accept', 'notif.action.decline',
'{"action":"flaky_approve","payload":{}}', '{"action":"flaky_approve","payload":{}}'
)
`).run(user.id, user.id).lastInsertRowid as number;
const failed = await respondToBoolean(id, user.id, 'positive');
expect(failed).toEqual({ success: false, error: 'downstream unavailable' });
const row = testDb.prepare('SELECT response, is_read FROM notifications WHERE id = ?').get(id) as { response: string | null; is_read: number };
expect(row.response).toBeNull();
expect(row.is_read).toBe(0);
shouldFail = false;
const retried = await respondToBoolean(id, user.id, 'positive');
expect(retried.success).toBe(true);
});
it('INOTIF-013 — respond result + broadcast carry an ISO-UTC created_at (toUtcIso, #1149 parity across paths)', async () => {
const { user } = createUser(testDb);
const id = insertBooleanNotification(user.id);
const result = await respondToBoolean(id, user.id, 'positive');
expect(result.notification?.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
const broadcastPayload = broadcastMock.mock.calls
.map((call) => call[1] as { type: string; notification: { created_at: string } })
.find((p) => p.type === 'notification:updated');
expect(broadcastPayload?.notification.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
});
it('INOTIF-014 — createNotification broadcast carries an ISO-UTC created_at (was the un-patched #1149 path)', () => {
const { user } = createUser(testDb);
createNotification({
type: 'simple',
scope: 'user',
target: user.id,
sender_id: null,
title_key: 'notif.test.title',
text_key: 'notif.test.text',
});
const broadcastPayload = broadcastMock.mock.calls
.map((call) => call[1] as { type: string; notification: { created_at: string } })
.find((p) => p.type === 'notification:new');
expect(broadcastPayload?.notification.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
});
});