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
319 lines
15 KiB
TypeScript
319 lines
15 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|