feat(notifications): add unified multi-channel notification system

Introduces a fully featured notification system with three delivery
channels (in-app, email, webhook), normalized per-user/per-event/
per-channel preferences, admin-scoped notifications, scheduled trip
reminders and version update alerts.

- New notificationService.send() as the single orchestration entry point
- In-app notifications with simple/boolean/navigate types and WebSocket push
- Per-user preference matrix with normalized notification_channel_preferences table
- Admin notification preferences stored globally in app_settings
- Migration 69 normalizes legacy notification_preferences table
- Scheduler hooks for daily trip reminders and version checks
- DevNotificationsPanel for testing in dev mode
- All new tests passing, covering dispatch, preferences, migration, boolean
  responses, resilience, and full API integration (NSVC, NPREF, INOTIF,
  MIGR, VNOTIF, NROUTE series)
 - Previous tests passing
This commit is contained in:
jubnl
2026-04-05 01:20:33 +02:00
parent 179938e904
commit fc29c5f7d0
46 changed files with 21923 additions and 18383 deletions

View File

@@ -0,0 +1,294 @@
/**
* Unit tests for in-app notification preference filtering in createNotification().
* Covers INOTIF-001 to INOTIF-004.
*/
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() }));
vi.mock('../../../src/websocket', () => ({ 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 { createNotification, createNotificationForRecipient, respondToBoolean } from '../../../src/services/inAppNotifications';
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);
});
});

View File

@@ -0,0 +1,234 @@
/**
* Unit tests for migration 69 (normalized notification preferences).
* Covers MIGR-001 to MIGR-004.
*/
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import Database from 'better-sqlite3';
import { createTables } from '../../../src/db/schema';
function buildFreshDb() {
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
return db;
}
/**
* Run all migrations up to (but NOT including) migration 69, then return the db.
* This allows us to set up old-schema data and test that migration 69 handles it.
*
* We do this by running only the schema tables that existed before migration 69,
* seeding old data, then running migration 69 in isolation.
*/
function setupPreMigration69Db() {
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
// Create schema_version and users table (bare minimum)
db.exec(`
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0);
INSERT INTO schema_version (version) VALUES (0);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
email TEXT,
role TEXT NOT NULL DEFAULT 'user'
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS notification_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notify_trip_invite INTEGER DEFAULT 1,
notify_booking_change INTEGER DEFAULT 1,
notify_trip_reminder INTEGER DEFAULT 1,
notify_vacay_invite INTEGER DEFAULT 1,
notify_photos_shared INTEGER DEFAULT 1,
notify_collab_message INTEGER DEFAULT 1,
notify_packing_tagged INTEGER DEFAULT 1,
notify_webhook INTEGER DEFAULT 1,
UNIQUE(user_id)
);
`);
return db;
}
/**
* Extract and run only migration 69 (index 68) from the migrations array.
* We do this by importing migrations and calling the last one directly.
*/
function runMigration69(db: ReturnType<typeof Database>): void {
// Migration 69 logic extracted inline for isolation
db.exec(`
CREATE TABLE IF NOT EXISTS notification_channel_preferences (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
channel TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (user_id, event_type, channel)
);
CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id);
`);
const oldPrefs = db.prepare('SELECT * FROM notification_preferences').all() as Array<Record<string, number>>;
const eventCols: Record<string, string> = {
trip_invite: 'notify_trip_invite',
booking_change: 'notify_booking_change',
trip_reminder: 'notify_trip_reminder',
vacay_invite: 'notify_vacay_invite',
photos_shared: 'notify_photos_shared',
collab_message: 'notify_collab_message',
packing_tagged: 'notify_packing_tagged',
};
const insert = db.prepare(
'INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
);
const insertMany = db.transaction((rows: Array<[number, string, string, number]>) => {
for (const [userId, eventType, channel, enabled] of rows) {
insert.run(userId, eventType, channel, enabled);
}
});
for (const row of oldPrefs) {
const userId = row.user_id as number;
const webhookEnabled = (row.notify_webhook as number) ?? 0;
const rows: Array<[number, string, string, number]> = [];
for (const [eventType, col] of Object.entries(eventCols)) {
const emailEnabled = (row[col] as number) ?? 1;
if (!emailEnabled) rows.push([userId, eventType, 'email', 0]);
if (!webhookEnabled) rows.push([userId, eventType, 'webhook', 0]);
}
if (rows.length > 0) insertMany(rows);
}
db.exec(`
INSERT OR IGNORE INTO app_settings (key, value)
SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel';
`);
}
// ─────────────────────────────────────────────────────────────────────────────
// Migration 69 tests
// ─────────────────────────────────────────────────────────────────────────────
describe('Migration 69 — normalized notification_channel_preferences', () => {
it('MIGR-001 — notification_channel_preferences table exists after migration', () => {
const db = setupPreMigration69Db();
runMigration69(db);
const table = db.prepare(
`SELECT name FROM sqlite_master WHERE type='table' AND name='notification_channel_preferences'`
).get();
expect(table).toBeDefined();
db.close();
});
it('MIGR-002 — old notification_preferences rows with disabled events migrated as enabled=0', () => {
const db = setupPreMigration69Db();
// Create a user
const userId = (db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('testuser', 'hash', 'user')).lastInsertRowid as number;
// Simulate user who has disabled trip_invite and booking_change email
db.prepare(`
INSERT INTO notification_preferences
(user_id, notify_trip_invite, notify_booking_change, notify_trip_reminder,
notify_vacay_invite, notify_photos_shared, notify_collab_message, notify_packing_tagged, notify_webhook)
VALUES (?, 0, 0, 1, 1, 1, 1, 1, 1)
`).run(userId);
runMigration69(db);
const tripInviteEmail = db.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(userId, 'trip_invite', 'email') as { enabled: number } | undefined;
const bookingEmail = db.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(userId, 'booking_change', 'email') as { enabled: number } | undefined;
const reminderEmail = db.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(userId, 'trip_reminder', 'email') as { enabled: number } | undefined;
// Disabled events should have enabled=0 rows
expect(tripInviteEmail).toBeDefined();
expect(tripInviteEmail!.enabled).toBe(0);
expect(bookingEmail).toBeDefined();
expect(bookingEmail!.enabled).toBe(0);
// Enabled events should have no row (no-row = enabled)
expect(reminderEmail).toBeUndefined();
db.close();
});
it('MIGR-003 — old notify_webhook=0 creates disabled webhook rows for all 7 events', () => {
const db = setupPreMigration69Db();
const userId = (db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('webhookuser', 'hash', 'user')).lastInsertRowid as number;
// User has all email enabled but webhook disabled
db.prepare(`
INSERT INTO notification_preferences
(user_id, notify_trip_invite, notify_booking_change, notify_trip_reminder,
notify_vacay_invite, notify_photos_shared, notify_collab_message, notify_packing_tagged, notify_webhook)
VALUES (?, 1, 1, 1, 1, 1, 1, 1, 0)
`).run(userId);
runMigration69(db);
const allEvents = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'];
for (const eventType of allEvents) {
const row = db.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(userId, eventType, 'webhook') as { enabled: number } | undefined;
expect(row).toBeDefined();
expect(row!.enabled).toBe(0);
// Email rows should NOT exist (all email was enabled → no row needed)
const emailRow = db.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(userId, eventType, 'email');
expect(emailRow).toBeUndefined();
}
db.close();
});
it('MIGR-004 — notification_channels key is created in app_settings from notification_channel value', () => {
const db = setupPreMigration69Db();
// Simulate existing single-channel setting
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channel', 'email');
runMigration69(db);
const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as { value: string } | undefined;
expect(plural).toBeDefined();
expect(plural!.value).toBe('email');
db.close();
});
it('MIGR-004b — notification_channels is not duplicated if already exists', () => {
const db = setupPreMigration69Db();
// Both keys already set (e.g. partial migration or manual edit)
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channel', 'email');
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channels', 'email,webhook');
runMigration69(db);
// The existing notification_channels value should be preserved (INSERT OR IGNORE)
const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as { value: string } | undefined;
expect(plural!.value).toBe('email,webhook');
db.close();
});
});

View File

@@ -0,0 +1,318 @@
/**
* Unit tests for notificationPreferencesService.
* Covers NPREF-001 to NPREF-021.
*/
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: () => {},
}));
vi.mock('../../../src/services/apiKeyCrypto', () => ({
decrypt_api_key: (v: string | null) => v,
maybe_encrypt_api_key: (v: string) => v,
encrypt_api_key: (v: string) => v,
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories';
import {
isEnabledForEvent,
getPreferencesMatrix,
setPreferences,
setAdminPreferences,
getAdminGlobalPref,
getActiveChannels,
getAvailableChannels,
} from '../../../src/services/notificationPreferencesService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// isEnabledForEvent
// ─────────────────────────────────────────────────────────────────────────────
describe('isEnabledForEvent', () => {
it('NPREF-001 — returns true when no row exists (default enabled)', () => {
const { user } = createUser(testDb);
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
});
it('NPREF-002 — returns true when row exists with enabled=1', () => {
const { user } = createUser(testDb);
testDb.prepare(
'INSERT INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 1)'
).run(user.id, 'trip_invite', 'email');
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
});
it('NPREF-003 — returns false when row exists with enabled=0', () => {
const { user } = createUser(testDb);
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// getPreferencesMatrix
// ─────────────────────────────────────────────────────────────────────────────
describe('getPreferencesMatrix', () => {
it('NPREF-004 — regular user does not see version_available in event_types', () => {
const { user } = createUser(testDb);
const { event_types } = getPreferencesMatrix(user.id, 'user');
expect(event_types).not.toContain('version_available');
expect(event_types.length).toBe(7);
});
it('NPREF-005 — user scope excludes version_available for everyone including admins', () => {
const { user } = createAdmin(testDb);
const { event_types } = getPreferencesMatrix(user.id, 'admin', 'user');
expect(event_types).not.toContain('version_available');
expect(event_types.length).toBe(7);
});
it('NPREF-005b — admin scope returns only version_available', () => {
const { user } = createAdmin(testDb);
const { event_types } = getPreferencesMatrix(user.id, 'admin', 'admin');
expect(event_types).toContain('version_available');
expect(event_types.length).toBe(1);
});
it('NPREF-006 — returns default true for all preferences when no stored prefs', () => {
const { user } = createUser(testDb);
const { preferences } = getPreferencesMatrix(user.id, 'user');
for (const [, channels] of Object.entries(preferences)) {
for (const [, enabled] of Object.entries(channels as Record<string, boolean>)) {
expect(enabled).toBe(true);
}
}
});
it('NPREF-007 — reflects stored disabled preferences in the matrix', () => {
const { user } = createUser(testDb);
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
disableNotificationPref(testDb, user.id, 'collab_message', 'webhook');
const { preferences } = getPreferencesMatrix(user.id, 'user');
expect(preferences['trip_invite']!['email']).toBe(false);
expect(preferences['collab_message']!['webhook']).toBe(false);
// Others unaffected
expect(preferences['trip_invite']!['webhook']).toBe(true);
expect(preferences['booking_change']!['email']).toBe(true);
});
it('NPREF-008 — available_channels.inapp is always true', () => {
const { user } = createUser(testDb);
const { available_channels } = getPreferencesMatrix(user.id, 'user');
expect(available_channels.inapp).toBe(true);
});
it('NPREF-009 — available_channels.email is true when email is in notification_channels', () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'email');
const { available_channels } = getPreferencesMatrix(user.id, 'user');
expect(available_channels.email).toBe(true);
});
it('NPREF-010 — available_channels.email is false when email is not in notification_channels', () => {
const { user } = createUser(testDb);
// No notification_channels set → defaults to none
const { available_channels } = getPreferencesMatrix(user.id, 'user');
expect(available_channels.email).toBe(false);
});
it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook]', () => {
const { user } = createAdmin(testDb);
const { implemented_combos } = getPreferencesMatrix(user.id, 'admin', 'admin');
expect(implemented_combos['version_available']).toEqual(['inapp', 'email', 'webhook']);
// All events now support all three channels
expect(implemented_combos['trip_invite']).toContain('inapp');
expect(implemented_combos['trip_invite']).toContain('email');
expect(implemented_combos['trip_invite']).toContain('webhook');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// setPreferences
// ─────────────────────────────────────────────────────────────────────────────
describe('setPreferences', () => {
it('NPREF-012 — disabling a preference inserts a row with enabled=0', () => {
const { user } = createUser(testDb);
setPreferences(user.id, { trip_invite: { email: false } });
const row = testDb.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(user.id, 'trip_invite', 'email') as { enabled: number } | undefined;
expect(row).toBeDefined();
expect(row!.enabled).toBe(0);
});
it('NPREF-013 — re-enabling a preference removes the disabled row', () => {
const { user } = createUser(testDb);
// First disable
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
// Then re-enable
setPreferences(user.id, { trip_invite: { email: true } });
const row = testDb.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(user.id, 'trip_invite', 'email');
// Row should be deleted — default is enabled
expect(row).toBeUndefined();
});
it('NPREF-014 — bulk update handles multiple event+channel combos', () => {
const { user } = createUser(testDb);
setPreferences(user.id, {
trip_invite: { email: false, webhook: false },
booking_change: { email: false },
trip_reminder: { webhook: true },
});
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
expect(isEnabledForEvent(user.id, 'trip_invite', 'webhook')).toBe(false);
expect(isEnabledForEvent(user.id, 'booking_change', 'email')).toBe(false);
// trip_reminder webhook was set to true → no row, default enabled
const row = testDb.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(user.id, 'trip_reminder', 'webhook');
expect(row).toBeUndefined();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// getActiveChannels
// ─────────────────────────────────────────────────────────────────────────────
describe('getActiveChannels', () => {
it('NPREF-015 — returns [] when notification_channels is none', () => {
setAppSetting(testDb, 'notification_channels', 'none');
expect(getActiveChannels()).toEqual([]);
});
it('NPREF-016 — returns [email] when notification_channels is email', () => {
setAppSetting(testDb, 'notification_channels', 'email');
expect(getActiveChannels()).toEqual(['email']);
});
it('NPREF-017 — returns [email, webhook] when notification_channels is email,webhook', () => {
setAppSetting(testDb, 'notification_channels', 'email,webhook');
expect(getActiveChannels()).toEqual(['email', 'webhook']);
});
it('NPREF-018 — falls back to notification_channel (singular) when plural key absent', () => {
// Only set the singular key
setAppSetting(testDb, 'notification_channel', 'webhook');
// No notification_channels key
expect(getActiveChannels()).toEqual(['webhook']);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// getAvailableChannels
// ─────────────────────────────────────────────────────────────────────────────
describe('getAvailableChannels', () => {
it('NPREF-019 — detects SMTP config from app_settings.smtp_host', () => {
setAppSetting(testDb, 'smtp_host', 'mail.example.com');
const channels = getAvailableChannels();
expect(channels.email).toBe(true);
expect(channels.inapp).toBe(true);
});
it('NPREF-020 — webhook available when admin has enabled the webhook channel', () => {
setNotificationChannels(testDb, 'webhook');
const channels = getAvailableChannels();
expect(channels.webhook).toBe(true);
});
it('NPREF-021 — detects SMTP config from env var SMTP_HOST', () => {
const original = process.env.SMTP_HOST;
process.env.SMTP_HOST = 'env-mail.example.com';
try {
const channels = getAvailableChannels();
expect(channels.email).toBe(true);
} finally {
if (original === undefined) delete process.env.SMTP_HOST;
else process.env.SMTP_HOST = original;
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// setAdminPreferences
// ─────────────────────────────────────────────────────────────────────────────
describe('setAdminPreferences', () => {
it('NPREF-022 — disabling email for version_available stores global pref in app_settings', () => {
const { user } = createAdmin(testDb);
setAdminPreferences(user.id, { version_available: { email: false } });
expect(getAdminGlobalPref('version_available', 'email')).toBe(false);
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined;
expect(row?.value).toBe('0');
});
it('NPREF-023 — disabling inapp for version_available stores per-user row in notification_channel_preferences', () => {
const { user } = createAdmin(testDb);
setAdminPreferences(user.id, { version_available: { inapp: false } });
const row = testDb.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(user.id, 'version_available', 'inapp') as { enabled: number } | undefined;
expect(row).toBeDefined();
expect(row!.enabled).toBe(0);
// Global app_settings should NOT have an inapp key
const globalRow = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_inapp');
expect(globalRow).toBeUndefined();
});
it('NPREF-024 — re-enabling inapp removes the disabled per-user row', () => {
const { user } = createAdmin(testDb);
// First disable
disableNotificationPref(testDb, user.id, 'version_available', 'inapp');
// Then re-enable via setAdminPreferences
setAdminPreferences(user.id, { version_available: { inapp: true } });
const row = testDb.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(user.id, 'version_available', 'inapp');
expect(row).toBeUndefined();
});
it('NPREF-025 — enabling email stores global pref as "1" in app_settings', () => {
const { user } = createAdmin(testDb);
// First disable, then re-enable
setAdminPreferences(user.id, { version_available: { email: false } });
setAdminPreferences(user.id, { version_available: { email: true } });
expect(getAdminGlobalPref('version_available', 'email')).toBe(true);
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined;
expect(row?.value).toBe('1');
});
});

View File

@@ -0,0 +1,455 @@
/**
* Unit tests for the unified notificationService.send().
* Covers NSVC-001 to NSVC-014.
*/
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: () => {},
}));
vi.mock('../../../src/services/apiKeyCrypto', () => ({
decrypt_api_key: (v: string | null) => v,
maybe_encrypt_api_key: (v: string) => v,
encrypt_api_key: (v: string) => v,
}));
const { sendMailMock, fetchMock, broadcastMock } = vi.hoisted(() => ({
sendMailMock: vi.fn().mockResolvedValue({ accepted: ['test@test.com'] }),
fetchMock: vi.fn(),
broadcastMock: vi.fn(),
}));
vi.mock('nodemailer', () => ({
default: {
createTransport: vi.fn(() => ({
sendMail: sendMailMock,
verify: vi.fn().mockResolvedValue(true),
})),
},
}));
vi.mock('node-fetch', () => ({ default: fetchMock }));
vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories';
import { send } from '../../../src/services/notificationService';
// ── Helpers ────────────────────────────────────────────────────────────────
function setSmtp(): void {
setAppSetting(testDb, 'smtp_host', 'mail.test.com');
setAppSetting(testDb, 'smtp_port', '587');
setAppSetting(testDb, 'smtp_from', 'trek@test.com');
}
function setUserWebhookUrl(userId: number, url = 'https://hooks.test.com/webhook'): void {
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'webhook_url', ?)").run(userId, url);
}
function setAdminWebhookUrl(url = 'https://hooks.test.com/admin-webhook'): void {
setAppSetting(testDb, 'admin_webhook_url', url);
}
function getInAppNotifications(recipientId: number) {
return testDb.prepare('SELECT * FROM notifications WHERE recipient_id = ? ORDER BY id').all(recipientId) as Array<{
id: number;
type: string;
scope: string;
navigate_target: string | null;
navigate_text_key: string | null;
title_key: string;
text_key: string;
}>;
}
function countAllNotifications(): number {
return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c;
}
// ── Setup ──────────────────────────────────────────────────────────────────
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
sendMailMock.mockClear();
fetchMock.mockClear();
broadcastMock.mockClear();
fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
});
afterAll(() => {
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// Multi-channel dispatch
// ─────────────────────────────────────────────────────────────────────────────
describe('send() — multi-channel dispatch', () => {
it('NSVC-001 — dispatches to all 3 channels (inapp, email, webhook) when all are active', async () => {
const { user } = createUser(testDb);
setSmtp();
setUserWebhookUrl(user.id);
setNotificationChannels(testDb, 'email,webhook');
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
expect(sendMailMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(broadcastMock).toHaveBeenCalledTimes(1);
expect(countAllNotifications()).toBe(1);
});
it('NSVC-002 — skips email/webhook when no channels are active (in-app still fires)', async () => {
const { user } = createUser(testDb);
setSmtp();
setUserWebhookUrl(user.id);
setNotificationChannels(testDb, 'none');
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Rome', user.id)).lastInsertRowid as number;
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
expect(sendMailMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
expect(broadcastMock).toHaveBeenCalledTimes(1);
expect(countAllNotifications()).toBe(1);
});
it('NSVC-003 — sends only email when only email channel is active', async () => {
const { user } = createUser(testDb);
setSmtp();
setNotificationChannels(testDb, 'email');
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Berlin', user.id)).lastInsertRowid as number;
await send({ event: 'booking_change', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Berlin', actor: 'Bob', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
expect(sendMailMock).toHaveBeenCalledTimes(1);
expect(fetchMock).not.toHaveBeenCalled();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Per-user preference filtering
// ─────────────────────────────────────────────────────────────────────────────
describe('send() — per-user preference filtering', () => {
it('NSVC-004 — skips email for a user who disabled trip_invite on email channel', async () => {
const { user } = createUser(testDb);
setSmtp();
setNotificationChannels(testDb, 'email');
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
expect(sendMailMock).not.toHaveBeenCalled();
// in-app still fires
expect(broadcastMock).toHaveBeenCalledTimes(1);
});
it('NSVC-005 — skips in-app for a user who disabled the event on inapp channel', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'none');
disableNotificationPref(testDb, user.id, 'collab_message', 'inapp');
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
await send({ event: 'collab_message', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', tripId: String(tripId) } });
expect(broadcastMock).not.toHaveBeenCalled();
expect(countAllNotifications()).toBe(0);
});
it('NSVC-006 — still sends webhook when user has email disabled but webhook enabled', async () => {
const { user } = createUser(testDb);
setSmtp();
setUserWebhookUrl(user.id);
setNotificationChannels(testDb, 'email,webhook');
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
expect(sendMailMock).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Recipient resolution
// ─────────────────────────────────────────────────────────────────────────────
describe('send() — recipient resolution', () => {
it('NSVC-007 — trip scope sends to owner + members, excludes actorId', async () => {
const { user: owner } = createUser(testDb);
const { user: member1 } = createUser(testDb);
const { user: member2 } = createUser(testDb);
const { user: actor } = createUser(testDb);
setNotificationChannels(testDb, 'none');
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', owner.id)).lastInsertRowid as number;
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member1.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member2.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, actor.id);
await send({ event: 'booking_change', actorId: actor.id, scope: 'trip', targetId: tripId, params: { trip: 'Trip', actor: 'Actor', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
// Owner, member1, member2 get it; actor is excluded
expect(countAllNotifications()).toBe(3);
const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id);
expect(recipients).toContain(owner.id);
expect(recipients).toContain(member1.id);
expect(recipients).toContain(member2.id);
expect(recipients).not.toContain(actor.id);
});
it('NSVC-008 — user scope sends to exactly one user', async () => {
const { user: target } = createUser(testDb);
const { user: other } = createUser(testDb);
setNotificationChannels(testDb, 'none');
await send({ event: 'vacay_invite', actorId: other.id, scope: 'user', targetId: target.id, params: { actor: 'other@test.com', planId: '42' } });
expect(countAllNotifications()).toBe(1);
const notif = testDb.prepare('SELECT recipient_id FROM notifications LIMIT 1').get() as { recipient_id: number };
expect(notif.recipient_id).toBe(target.id);
});
it('NSVC-009 — admin scope sends to all admins (not regular users)', async () => {
const { user: admin1 } = createAdmin(testDb);
const { user: admin2 } = createAdmin(testDb);
createUser(testDb); // regular user — should NOT receive
setNotificationChannels(testDb, 'none');
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '2.0.0' } });
expect(countAllNotifications()).toBe(2);
const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id);
expect(recipients).toContain(admin1.id);
expect(recipients).toContain(admin2.id);
});
it('NSVC-010 — admin scope fires admin webhook URL when set', async () => {
createAdmin(testDb);
setAdminWebhookUrl();
setNotificationChannels(testDb, 'none');
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '2.0.0' } });
// Wait for fire-and-forget admin webhook
await new Promise(r => setTimeout(r, 10));
expect(fetchMock).toHaveBeenCalledTimes(1);
const callUrl = fetchMock.mock.calls[0][0];
expect(callUrl).toBe('https://hooks.test.com/admin-webhook');
});
it('NSVC-011 — does nothing when there are no recipients', async () => {
// Trip with no members, sending as the trip owner (actor excluded from trip scope)
const { user: owner } = createUser(testDb);
setNotificationChannels(testDb, 'none');
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Solo', owner.id)).lastInsertRowid as number;
await send({ event: 'booking_change', actorId: owner.id, scope: 'trip', targetId: tripId, params: { trip: 'Solo', actor: 'owner@test.com', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
expect(countAllNotifications()).toBe(0);
expect(broadcastMock).not.toHaveBeenCalled();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// In-app notification content
// ─────────────────────────────────────────────────────────────────────────────
describe('send() — in-app notification content', () => {
it('NSVC-012 — creates navigate in-app notification with correct title/text/navigate keys', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'none');
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '42' } });
const notifs = getInAppNotifications(user.id);
expect(notifs.length).toBe(1);
expect(notifs[0].type).toBe('navigate');
expect(notifs[0].title_key).toBe('notif.trip_invite.title');
expect(notifs[0].text_key).toBe('notif.trip_invite.text');
expect(notifs[0].navigate_text_key).toBe('notif.action.view_trip');
expect(notifs[0].navigate_target).toBe('/trips/42');
});
it('NSVC-013 — creates simple in-app notification when no navigate target is available', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'none');
// vacay_invite without planId → no navigate target → simple type
await send({ event: 'vacay_invite', actorId: null, scope: 'user', targetId: user.id, params: { actor: 'Alice' } });
const notifs = getInAppNotifications(user.id);
expect(notifs.length).toBe(1);
expect(notifs[0].type).toBe('simple');
expect(notifs[0].navigate_target).toBeNull();
});
it('NSVC-014 — navigate_target uses /admin for version_available event', async () => {
const { user: admin } = createAdmin(testDb);
setNotificationChannels(testDb, 'none');
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '9.9.9' } });
const notifs = getInAppNotifications(admin.id);
expect(notifs.length).toBe(1);
expect(notifs[0].navigate_target).toBe('/admin');
expect(notifs[0].title_key).toBe('notif.version_available.title');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Email/webhook link generation
// ─────────────────────────────────────────────────────────────────────────────
describe('send() — email/webhook links', () => {
it('NSVC-015 — email subject and body are localized per recipient language', async () => {
const { user } = createUser(testDb);
setSmtp();
setNotificationChannels(testDb, 'email');
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
// Set user language to French
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'language', 'fr')").run(user.id);
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
expect(sendMailMock).toHaveBeenCalledTimes(1);
const mailArgs = sendMailMock.mock.calls[0][0];
// French title for trip_invite should contain "Invitation"
expect(mailArgs.subject).toContain('Invitation');
});
it('NSVC-016 — webhook payload includes link field when navigate target is available', async () => {
const { user } = createUser(testDb);
setUserWebhookUrl(user.id, 'https://hooks.test.com/generic-webhook');
setNotificationChannels(testDb, 'webhook');
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '55' } });
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
// Generic webhook — link should contain /trips/55
expect(body.link).toContain('/trips/55');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Boolean in-app type
// ─────────────────────────────────────────────────────────────────────────────
describe('send() — boolean in-app type', () => {
it('NSVC-017 — creates boolean in-app notification with callbacks when inApp.type override is boolean', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'none');
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '1' },
inApp: {
type: 'boolean',
positiveTextKey: 'notif.action.accept',
negativeTextKey: 'notif.action.decline',
positiveCallback: { action: 'test_approve', payload: { tripId: 1 } },
negativeCallback: { action: 'test_deny', payload: { tripId: 1 } },
},
});
const notifs = getInAppNotifications(user.id);
expect(notifs.length).toBe(1);
const row = notifs[0] as any;
expect(row.type).toBe('boolean');
expect(row.positive_callback).toContain('test_approve');
expect(row.negative_callback).toContain('test_deny');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Channel failure resilience
// ─────────────────────────────────────────────────────────────────────────────
describe('send() — channel failure resilience', () => {
it('NSVC-018 — email failure does not prevent in-app or webhook delivery', async () => {
const { user } = createUser(testDb);
setSmtp();
setUserWebhookUrl(user.id);
setNotificationChannels(testDb, 'email,webhook');
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
// Make email throw
sendMailMock.mockRejectedValueOnce(new Error('SMTP connection refused'));
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
// In-app and webhook still fire despite email failure
expect(broadcastMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(countAllNotifications()).toBe(1);
});
it('NSVC-019 — webhook failure does not prevent in-app or email delivery', async () => {
const { user } = createUser(testDb);
setSmtp();
setUserWebhookUrl(user.id);
setNotificationChannels(testDb, 'email,webhook');
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
// Make webhook throw
fetchMock.mockRejectedValueOnce(new Error('Network error'));
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
// In-app and email still fire despite webhook failure
expect(broadcastMock).toHaveBeenCalledTimes(1);
expect(sendMailMock).toHaveBeenCalledTimes(1);
expect(countAllNotifications()).toBe(1);
});
});

View File

@@ -0,0 +1,173 @@
/**
* Unit tests for checkAndNotifyVersion() in adminService.
* Covers VNOTIF-001 to VNOTIF-007.
*/
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: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
// Mock MCP to avoid session side-effects
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createAdmin } from '../../helpers/factories';
import { checkAndNotifyVersion } from '../../../src/services/adminService';
// Helper: mock the GitHub releases/latest endpoint
function mockGitHubLatest(tagName: string, ok = true): void {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok,
json: async () => ({ tag_name: tagName, html_url: `https://github.com/mauriceboe/TREK/releases/tag/${tagName}` }),
}));
}
function mockGitHubFetchFailure(): void {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
}
function getLastNotifiedVersion(): string | undefined {
return (testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as { value: string } | undefined)?.value;
}
function getNotificationCount(): number {
return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c;
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
vi.unstubAllGlobals();
});
afterAll(() => {
testDb.close();
vi.unstubAllGlobals();
});
// ─────────────────────────────────────────────────────────────────────────────
// checkAndNotifyVersion
// ─────────────────────────────────────────────────────────────────────────────
describe('checkAndNotifyVersion', () => {
it('VNOTIF-001 — does nothing when no update is available', async () => {
createAdmin(testDb);
// GitHub reports same version as package.json (or older) → update_available: false
const { version } = require('../../../package.json');
mockGitHubLatest(`v${version}`);
await checkAndNotifyVersion();
expect(getNotificationCount()).toBe(0);
expect(getLastNotifiedVersion()).toBeUndefined();
});
it('VNOTIF-002 — creates a navigate notification for all admins when update available', async () => {
const { user: admin1 } = createAdmin(testDb);
const { user: admin2 } = createAdmin(testDb);
mockGitHubLatest('v99.0.0');
await checkAndNotifyVersion();
const notifications = testDb.prepare('SELECT * FROM notifications ORDER BY id').all() as Array<{ recipient_id: number; type: string; scope: string }>;
expect(notifications.length).toBe(2);
const recipientIds = notifications.map(n => n.recipient_id);
expect(recipientIds).toContain(admin1.id);
expect(recipientIds).toContain(admin2.id);
expect(notifications[0].type).toBe('navigate');
expect(notifications[0].scope).toBe('admin');
});
it('VNOTIF-003 — sets last_notified_version in app_settings after notifying', async () => {
createAdmin(testDb);
mockGitHubLatest('v99.1.0');
await checkAndNotifyVersion();
expect(getLastNotifiedVersion()).toBe('99.1.0');
});
it('VNOTIF-004 — does NOT create duplicate notification if last_notified_version matches', async () => {
createAdmin(testDb);
mockGitHubLatest('v99.2.0');
// First call notifies
await checkAndNotifyVersion();
const countAfterFirst = getNotificationCount();
expect(countAfterFirst).toBe(1);
// Second call with same version — should not create another
await checkAndNotifyVersion();
expect(getNotificationCount()).toBe(countAfterFirst);
});
it('VNOTIF-005 — creates new notification when last_notified_version is an older version', async () => {
createAdmin(testDb);
// Simulate having been notified about an older version
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('last_notified_version', '98.0.0');
mockGitHubLatest('v99.3.0');
await checkAndNotifyVersion();
expect(getNotificationCount()).toBe(1);
expect(getLastNotifiedVersion()).toBe('99.3.0');
});
it('VNOTIF-006 — notification has correct type, scope, and navigate_target', async () => {
createAdmin(testDb);
mockGitHubLatest('v99.4.0');
await checkAndNotifyVersion();
const notif = testDb.prepare('SELECT * FROM notifications LIMIT 1').get() as {
type: string;
scope: string;
navigate_target: string;
title_key: string;
text_key: string;
navigate_text_key: string;
};
expect(notif.type).toBe('navigate');
expect(notif.scope).toBe('admin');
expect(notif.navigate_target).toBe('/admin');
expect(notif.title_key).toBe('notif.version_available.title');
expect(notif.text_key).toBe('notif.version_available.text');
expect(notif.navigate_text_key).toBe('notif.action.view_admin');
});
it('VNOTIF-007 — silently handles GitHub API fetch failure (no crash, no notification)', async () => {
createAdmin(testDb);
mockGitHubFetchFailure();
// Should not throw
await expect(checkAndNotifyVersion()).resolves.toBeUndefined();
expect(getNotificationCount()).toBe(0);
expect(getLastNotifiedVersion()).toBeUndefined();
});
});