|
@@ -224,7 +281,7 @@ export function buildEmailHtml(subject: string, body: string, lang: string): str
// ── Send functions ─────────────────────────────────────────────────────────
-async function sendEmail(to: string, subject: string, body: string, userId?: number): Promise {
+export async function sendEmail(to: string, subject: string, body: string, userId?: number, navigateTarget?: string): Promise {
const config = getSmtpConfig();
if (!config) return false;
@@ -245,7 +302,7 @@ async function sendEmail(to: string, subject: string, body: string, userId?: num
to,
subject: `TREK — ${subject}`,
text: body,
- html: buildEmailHtml(subject, body, lang),
+ html: buildEmailHtml(subject, body, lang, navigateTarget),
});
logInfo(`Email sent to=${to} subject="${subject}"`);
logDebug(`Email smtp=${config.host}:${config.port} from=${config.from} to=${to}`);
@@ -256,7 +313,7 @@ async function sendEmail(to: string, subject: string, body: string, userId?: num
}
}
-export function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string }): string {
+export function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string; link?: string }): string {
const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url);
const isSlack = /hooks\.slack\.com\//.test(url);
@@ -265,6 +322,7 @@ export function buildWebhookBody(url: string, payload: { event: string; title: s
embeds: [{
title: `📍 ${payload.title}`,
description: payload.body,
+ url: payload.link,
color: 0x3b82f6,
footer: { text: payload.tripName ? `Trip: ${payload.tripName}` : 'TREK' },
timestamp: new Date().toISOString(),
@@ -274,24 +332,32 @@ export function buildWebhookBody(url: string, payload: { event: string; title: s
if (isSlack) {
const trip = payload.tripName ? ` • _${payload.tripName}_` : '';
+ const link = payload.link ? `\n<${payload.link}|Open in TREK>` : '';
return JSON.stringify({
- text: `*${payload.title}*\n${payload.body}${trip}`,
+ text: `*${payload.title}*\n${payload.body}${trip}${link}`,
});
}
return JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' });
}
-async function sendWebhook(payload: { event: string; title: string; body: string; tripName?: string }): Promise {
- const url = getWebhookUrl();
+export async function sendWebhook(url: string, payload: { event: string; title: string; body: string; tripName?: string; link?: string }): Promise {
if (!url) return false;
+ const ssrf = await checkSsrf(url);
+ if (!ssrf.allowed) {
+ logError(`Webhook blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`);
+ return false;
+ }
+
try {
+ const agent = createPinnedAgent(ssrf.resolvedIp!, new URL(url).protocol);
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: buildWebhookBody(url, payload),
signal: AbortSignal.timeout(10000),
+ agent,
});
if (!res.ok) {
@@ -309,56 +375,6 @@ async function sendWebhook(payload: { event: string; title: string; body: string
}
}
-// ── Public API ─────────────────────────────────────────────────────────────
-
-function getNotificationChannel(): string {
- return getAppSetting('notification_channel') || 'none';
-}
-
-export async function notify(payload: NotificationPayload): Promise {
- const channel = getNotificationChannel();
- if (channel === 'none') return;
-
- if (!getAdminEventEnabled(payload.event)) return;
-
- const lang = getUserLanguage(payload.userId);
- const { title, body } = getEventText(lang, payload.event, payload.params);
-
- logDebug(`Notification event=${payload.event} channel=${channel} userId=${payload.userId} params=${JSON.stringify(payload.params)}`);
-
- if (channel === 'email') {
- const email = getUserEmail(payload.userId);
- if (email) await sendEmail(email, title, body, payload.userId);
- } else if (channel === 'webhook') {
- await sendWebhook({ event: payload.event, title, body, tripName: payload.params.trip });
- }
-}
-
-export async function notifyTripMembers(tripId: number, actorUserId: number, event: EventType, params: Record): Promise {
- const channel = getNotificationChannel();
- if (channel === 'none') return;
- if (!getAdminEventEnabled(event)) return;
-
- const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
- if (!trip) return;
-
- if (channel === 'webhook') {
- const lang = getUserLanguage(actorUserId);
- const { title, body } = getEventText(lang, event, params);
- logDebug(`notifyTripMembers event=${event} channel=webhook tripId=${tripId} actor=${actorUserId}`);
- await sendWebhook({ event, title, body, tripName: params.trip });
- return;
- }
-
- const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(tripId) as { user_id: number }[];
- const allIds = [trip.user_id, ...members.map(m => m.user_id)].filter(id => id !== actorUserId);
- const unique = [...new Set(allIds)];
-
- for (const userId of unique) {
- await notify({ userId, event, params });
- }
-}
-
export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
try {
const sent = await sendEmail(to, 'Test Notification', 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.');
@@ -368,11 +384,12 @@ export async function testSmtp(to: string): Promise<{ success: boolean; error?:
}
}
-export async function testWebhook(): Promise<{ success: boolean; error?: string }> {
+export async function testWebhook(url: string): Promise<{ success: boolean; error?: string }> {
try {
- const sent = await sendWebhook({ event: 'test', title: 'Test Notification', body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.' });
- return sent ? { success: true } : { success: false, error: 'Webhook URL not configured' };
+ const sent = await sendWebhook(url, { event: 'test', title: 'Test Notification', body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.' });
+ return sent ? { success: true } : { success: false, error: 'Failed to send webhook' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
}
}
+
diff --git a/server/src/services/settingsService.ts b/server/src/services/settingsService.ts
index 7aa49d8..12a0388 100644
--- a/server/src/services/settingsService.ts
+++ b/server/src/services/settingsService.ts
@@ -1,9 +1,16 @@
import { db } from '../db/database';
+import { maybe_encrypt_api_key } from './apiKeyCrypto';
+
+const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url']);
export function getUserSettings(userId: number): Record {
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[];
const settings: Record = {};
for (const row of rows) {
+ if (ENCRYPTED_SETTING_KEYS.has(row.key)) {
+ settings[row.key] = row.value ? '••••••••' : '';
+ continue;
+ }
try {
settings[row.key] = JSON.parse(row.value);
} catch {
@@ -13,12 +20,17 @@ export function getUserSettings(userId: number): Record {
return settings;
}
+function serializeValue(key: string, value: unknown): string {
+ const raw = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
+ if (ENCRYPTED_SETTING_KEYS.has(key)) return maybe_encrypt_api_key(raw) ?? raw;
+ return raw;
+}
+
export function upsertSetting(userId: number, key: string, value: unknown) {
- const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
db.prepare(`
INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
- `).run(userId, key, serialized);
+ `).run(userId, key, serializeValue(key, value));
}
export function bulkUpsertSettings(userId: number, settings: Record) {
@@ -29,8 +41,7 @@ export function bulkUpsertSettings(userId: number, settings: Record {
- notify({ userId: targetUserId, event: 'vacay_invite', params: { actor: inviterEmail } }).catch(() => {});
+ import('../services/notificationService').then(({ send }) => {
+ send({ event: 'vacay_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, planId: String(planId) } }).catch(() => {});
});
return {};
diff --git a/server/src/utils/ssrfGuard.ts b/server/src/utils/ssrfGuard.ts
index 6927a1d..afaf201 100644
--- a/server/src/utils/ssrfGuard.ts
+++ b/server/src/utils/ssrfGuard.ts
@@ -112,10 +112,15 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
*/
export function createPinnedAgent(resolvedIp: string, protocol: string): http.Agent | https.Agent {
const options = {
- lookup: (_hostname: string, _opts: unknown, callback: (err: Error | null, addr: string, family: number) => void) => {
+ lookup: (_hostname: string, opts: Record, callback: Function) => {
// Determine address family from IP format
const family = resolvedIp.includes(':') ? 6 : 4;
- callback(null, resolvedIp, family);
+ // Node.js 18+ may call lookup with `all: true`, expecting an array of address objects
+ if (opts && opts.all) {
+ callback(null, [{ address: resolvedIp, family }]);
+ } else {
+ callback(null, resolvedIp, family);
+ }
},
};
return protocol === 'https:' ? new https.Agent(options) : new http.Agent(options);
diff --git a/server/tests/helpers/factories.ts b/server/tests/helpers/factories.ts
index 360467a..142080c 100644
--- a/server/tests/helpers/factories.ts
+++ b/server/tests/helpers/factories.ts
@@ -480,3 +480,29 @@ export function createInviteToken(
).run(token, overrides.max_uses ?? 1, overrides.expires_at ?? null, createdBy);
return db.prepare('SELECT * FROM invite_tokens WHERE id = ?').get(result.lastInsertRowid) as TestInviteToken;
}
+
+// ---------------------------------------------------------------------------
+// Notification helpers
+// ---------------------------------------------------------------------------
+
+/** Upsert a key/value pair into app_settings. */
+export function setAppSetting(db: Database.Database, key: string, value: string): void {
+ db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value);
+}
+
+/** Set the active notification channels (e.g. 'email', 'webhook', 'email,webhook', 'none'). */
+export function setNotificationChannels(db: Database.Database, channels: string): void {
+ setAppSetting(db, 'notification_channels', channels);
+}
+
+/** Explicitly disable a per-user notification preference for a given event+channel combo. */
+export function disableNotificationPref(
+ db: Database.Database,
+ userId: number,
+ eventType: string,
+ channel: string
+): void {
+ db.prepare(
+ 'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 0)'
+ ).run(userId, eventType, channel);
+}
diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts
index bae53a8..436e190 100644
--- a/server/tests/helpers/test-db.ts
+++ b/server/tests/helpers/test-db.ts
@@ -56,6 +56,7 @@ const RESET_TABLES = [
'vacay_plans',
'atlas_visited_countries',
'atlas_bucket_list',
+ 'notification_channel_preferences',
'notifications',
'audit_log',
'user_settings',
diff --git a/server/tests/integration/notifications.test.ts b/server/tests/integration/notifications.test.ts
index e46923c..cb37aba 100644
--- a/server/tests/integration/notifications.test.ts
+++ b/server/tests/integration/notifications.test.ts
@@ -39,12 +39,13 @@ vi.mock('../../src/config', () => ({
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
+vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
-import { createUser } from '../helpers/factories';
+import { createUser, createAdmin, disableNotificationPref } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
@@ -154,6 +155,137 @@ describe('In-app notifications', () => {
});
});
+// ─────────────────────────────────────────────────────────────────────────────
+// New preferences matrix API (NROUTE series)
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('GET /api/notifications/preferences — matrix format', () => {
+ it('NROUTE-002 — returns preferences, available_channels, event_types, implemented_combos', async () => {
+ const { user } = createUser(testDb);
+ const res = await request(app)
+ .get('/api/notifications/preferences')
+ .set('Cookie', authCookie(user.id));
+ expect(res.status).toBe(200);
+ expect(res.body).toHaveProperty('preferences');
+ expect(res.body).toHaveProperty('available_channels');
+ expect(res.body).toHaveProperty('event_types');
+ expect(res.body).toHaveProperty('implemented_combos');
+ expect(res.body.available_channels.inapp).toBe(true);
+ });
+
+ it('NROUTE-003 — regular user does not see version_available in event_types', async () => {
+ const { user } = createUser(testDb);
+ const res = await request(app)
+ .get('/api/notifications/preferences')
+ .set('Cookie', authCookie(user.id));
+ expect(res.status).toBe(200);
+ expect(res.body.event_types).not.toContain('version_available');
+ });
+
+ it('NROUTE-004 — user preferences endpoint excludes version_available even for admins', async () => {
+ const { user } = createAdmin(testDb);
+ const res = await request(app)
+ .get('/api/notifications/preferences')
+ .set('Cookie', authCookie(user.id));
+ expect(res.status).toBe(200);
+ expect(res.body.event_types).not.toContain('version_available');
+ });
+
+ it('NROUTE-004b — admin notification preferences endpoint returns version_available', async () => {
+ const { user } = createAdmin(testDb);
+ const res = await request(app)
+ .get('/api/admin/notification-preferences')
+ .set('Cookie', authCookie(user.id));
+ expect(res.status).toBe(200);
+ expect(res.body.event_types).toContain('version_available');
+ });
+
+ it('NROUTE-005 — all preferences default to true for new user with no stored prefs', async () => {
+ const { user } = createUser(testDb);
+ const res = await request(app)
+ .get('/api/notifications/preferences')
+ .set('Cookie', authCookie(user.id));
+ expect(res.status).toBe(200);
+ const { preferences } = res.body;
+ for (const [, channels] of Object.entries(preferences)) {
+ for (const [, enabled] of Object.entries(channels as Record)) {
+ expect(enabled).toBe(true);
+ }
+ }
+ });
+});
+
+describe('PUT /api/notifications/preferences — matrix format', () => {
+ it('NROUTE-007 — disabling a preference persists and is reflected in subsequent GET', async () => {
+ const { user } = createUser(testDb);
+
+ const putRes = await request(app)
+ .put('/api/notifications/preferences')
+ .set('Cookie', authCookie(user.id))
+ .send({ trip_invite: { email: false } });
+
+ expect(putRes.status).toBe(200);
+ expect(putRes.body.preferences['trip_invite']['email']).toBe(false);
+
+ const getRes = await request(app)
+ .get('/api/notifications/preferences')
+ .set('Cookie', authCookie(user.id));
+ expect(getRes.body.preferences['trip_invite']['email']).toBe(false);
+ });
+
+ it('NROUTE-008 — re-enabling a preference restores default state', async () => {
+ const { user } = createUser(testDb);
+ disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
+
+ const res = await request(app)
+ .put('/api/notifications/preferences')
+ .set('Cookie', authCookie(user.id))
+ .send({ trip_invite: { email: true } });
+
+ expect(res.status).toBe(200);
+ expect(res.body.preferences['trip_invite']['email']).toBe(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');
+ expect(row).toBeUndefined();
+ });
+
+ it('NROUTE-009 — partial update does not affect other preferences', async () => {
+ const { user } = createUser(testDb);
+ disableNotificationPref(testDb, user.id, 'booking_change', 'email');
+
+ await request(app)
+ .put('/api/notifications/preferences')
+ .set('Cookie', authCookie(user.id))
+ .send({ trip_invite: { email: false } });
+
+ const getRes = await request(app)
+ .get('/api/notifications/preferences')
+ .set('Cookie', authCookie(user.id));
+ expect(getRes.body.preferences['booking_change']['email']).toBe(false);
+ expect(getRes.body.preferences['trip_invite']['email']).toBe(false);
+ expect(getRes.body.preferences['trip_reminder']['email']).toBe(true);
+ });
+});
+
+describe('implemented_combos — in-app channel coverage', () => {
+ it('NROUTE-010 — implemented_combos includes inapp for all event types', async () => {
+ const { user } = createUser(testDb);
+ const res = await request(app)
+ .get('/api/notifications/preferences')
+ .set('Cookie', authCookie(user.id));
+ expect(res.status).toBe(200);
+ const { implemented_combos } = res.body as { implemented_combos: Record };
+ const eventTypes = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'];
+ for (const event of eventTypes) {
+ expect(implemented_combos[event], `${event} should support inapp`).toContain('inapp');
+ expect(implemented_combos[event], `${event} should support email`).toContain('email');
+ expect(implemented_combos[event], `${event} should support webhook`).toContain('webhook');
+ }
+ });
+});
+
describe('Notification test endpoints', () => {
it('NOTIF-005 — POST /api/notifications/test-smtp requires admin', async () => {
const { user } = createUser(testDb);
@@ -165,13 +297,244 @@ describe('Notification test endpoints', () => {
expect(res.status).toBe(403);
});
- it('NOTIF-006 — POST /api/notifications/test-webhook requires admin', async () => {
+ it('NOTIF-006 — POST /api/notifications/test-webhook returns 400 when url is missing', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/notifications/test-webhook')
.set('Cookie', authCookie(user.id))
.send({});
+ expect(res.status).toBe(400);
+ });
+
+ it('NOTIF-006b — POST /api/notifications/test-webhook returns 400 for invalid URL', async () => {
+ const { user } = createUser(testDb);
+
+ const res = await request(app)
+ .post('/api/notifications/test-webhook')
+ .set('Cookie', authCookie(user.id))
+ .send({ url: 'not-a-url' });
+ expect(res.status).toBe(400);
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Helper: insert a boolean notification directly into the DB
+// ─────────────────────────────────────────────────────────────────────────────
+
+function insertBooleanNotification(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,
+ 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":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}'
+ )
+ `).run(recipientId, 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;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// POST /in-app/:id/respond
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('POST /api/notifications/in-app/:id/respond', () => {
+ it('NROUTE-011 — valid positive response returns success and updated notification', async () => {
+ const { user } = createUser(testDb);
+ const id = insertBooleanNotification(user.id);
+
+ const res = await request(app)
+ .post(`/api/notifications/in-app/${id}/respond`)
+ .set('Cookie', authCookie(user.id))
+ .send({ response: 'positive' });
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ expect(res.body.notification).toBeDefined();
+ expect(res.body.notification.response).toBe('positive');
+ });
+
+ it('NROUTE-012 — invalid response value returns 400', async () => {
+ const { user } = createUser(testDb);
+ const id = insertBooleanNotification(user.id);
+
+ const res = await request(app)
+ .post(`/api/notifications/in-app/${id}/respond`)
+ .set('Cookie', authCookie(user.id))
+ .send({ response: 'maybe' });
+
+ expect(res.status).toBe(400);
+ });
+
+ it('NROUTE-013 — response on non-existent notification returns 400', async () => {
+ const { user } = createUser(testDb);
+
+ const res = await request(app)
+ .post('/api/notifications/in-app/99999/respond')
+ .set('Cookie', authCookie(user.id))
+ .send({ response: 'positive' });
+
+ expect(res.status).toBe(400);
+ });
+
+ it('NROUTE-014 — double response returns 400', async () => {
+ const { user } = createUser(testDb);
+ const id = insertBooleanNotification(user.id);
+
+ await request(app)
+ .post(`/api/notifications/in-app/${id}/respond`)
+ .set('Cookie', authCookie(user.id))
+ .send({ response: 'positive' });
+
+ const res = await request(app)
+ .post(`/api/notifications/in-app/${id}/respond`)
+ .set('Cookie', authCookie(user.id))
+ .send({ response: 'negative' });
+
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/already responded/i);
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// PUT /api/admin/notification-preferences
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('PUT /api/admin/notification-preferences', () => {
+ it('NROUTE-015 — admin can disable email for version_available, persists in GET', async () => {
+ const { user } = createAdmin(testDb);
+
+ const putRes = await request(app)
+ .put('/api/admin/notification-preferences')
+ .set('Cookie', authCookie(user.id))
+ .send({ version_available: { email: false } });
+
+ expect(putRes.status).toBe(200);
+ expect(putRes.body.preferences['version_available']['email']).toBe(false);
+
+ const getRes = await request(app)
+ .get('/api/admin/notification-preferences')
+ .set('Cookie', authCookie(user.id));
+ expect(getRes.status).toBe(200);
+ expect(getRes.body.preferences['version_available']['email']).toBe(false);
+ });
+
+ it('NROUTE-016 — non-admin is rejected with 403', async () => {
+ const { user } = createUser(testDb);
+
+ const res = await request(app)
+ .put('/api/admin/notification-preferences')
+ .set('Cookie', authCookie(user.id))
+ .send({ version_available: { email: false } });
+
expect(res.status).toBe(403);
});
});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// In-app CRUD with actual notification data
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('In-app notifications — CRUD with data', () => {
+ it('NROUTE-017 — GET /in-app returns created notifications', async () => {
+ const { user } = createUser(testDb);
+ insertSimpleNotification(user.id);
+ insertSimpleNotification(user.id);
+
+ const res = await request(app)
+ .get('/api/notifications/in-app')
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(200);
+ expect(res.body.notifications.length).toBe(2);
+ expect(res.body.total).toBe(2);
+ expect(res.body.unread_count).toBe(2);
+ });
+
+ it('NROUTE-018 — unread count reflects actual unread notifications', async () => {
+ const { user } = createUser(testDb);
+ insertSimpleNotification(user.id);
+ insertSimpleNotification(user.id);
+
+ const res = await request(app)
+ .get('/api/notifications/in-app/unread-count')
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(200);
+ expect(res.body.count).toBe(2);
+ });
+
+ it('NROUTE-019 — mark-read on existing notification succeeds and decrements unread count', async () => {
+ const { user } = createUser(testDb);
+ const id = insertSimpleNotification(user.id);
+
+ const markRes = await request(app)
+ .put(`/api/notifications/in-app/${id}/read`)
+ .set('Cookie', authCookie(user.id));
+ expect(markRes.status).toBe(200);
+ expect(markRes.body.success).toBe(true);
+
+ const countRes = await request(app)
+ .get('/api/notifications/in-app/unread-count')
+ .set('Cookie', authCookie(user.id));
+ expect(countRes.body.count).toBe(0);
+ });
+
+ it('NROUTE-020 — mark-unread on a read notification succeeds', async () => {
+ const { user } = createUser(testDb);
+ const id = insertSimpleNotification(user.id);
+ // Mark read first
+ testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(id);
+
+ const res = await request(app)
+ .put(`/api/notifications/in-app/${id}/unread`)
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(id) as { is_read: number };
+ expect(row.is_read).toBe(0);
+ });
+
+ it('NROUTE-021 — DELETE on existing notification removes it', async () => {
+ const { user } = createUser(testDb);
+ const id = insertSimpleNotification(user.id);
+
+ const res = await request(app)
+ .delete(`/api/notifications/in-app/${id}`)
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ const row = testDb.prepare('SELECT id FROM notifications WHERE id = ?').get(id);
+ expect(row).toBeUndefined();
+ });
+
+ it('NROUTE-022 — unread_only=true filter returns only unread notifications', async () => {
+ const { user } = createUser(testDb);
+ const id1 = insertSimpleNotification(user.id);
+ insertSimpleNotification(user.id);
+ // Mark first one read
+ testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(id1);
+
+ const res = await request(app)
+ .get('/api/notifications/in-app?unread_only=true')
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(200);
+ expect(res.body.notifications.length).toBe(1);
+ expect(res.body.notifications[0].is_read).toBe(0);
+ });
+});
diff --git a/server/tests/unit/services/inAppNotificationPrefs.test.ts b/server/tests/unit/services/inAppNotificationPrefs.test.ts
new file mode 100644
index 0000000..e713eca
--- /dev/null
+++ b/server/tests/unit/services/inAppNotificationPrefs.test.ts
@@ -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);
+ });
+});
diff --git a/server/tests/unit/services/migration.test.ts b/server/tests/unit/services/migration.test.ts
new file mode 100644
index 0000000..053101a
--- /dev/null
+++ b/server/tests/unit/services/migration.test.ts
@@ -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): 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>;
+ const eventCols: Record = {
+ 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();
+ });
+});
diff --git a/server/tests/unit/services/notificationPreferencesService.test.ts b/server/tests/unit/services/notificationPreferencesService.test.ts
new file mode 100644
index 0000000..2cc09c1
--- /dev/null
+++ b/server/tests/unit/services/notificationPreferencesService.test.ts
@@ -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)) {
+ 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');
+ });
+});
diff --git a/server/tests/unit/services/notificationService.test.ts b/server/tests/unit/services/notificationService.test.ts
new file mode 100644
index 0000000..a6b24f8
--- /dev/null
+++ b/server/tests/unit/services/notificationService.test.ts
@@ -0,0 +1,459 @@
+/**
+ * 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 }));
+vi.mock('../../../src/utils/ssrfGuard', () => ({
+ checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })),
+ createPinnedAgent: vi.fn(() => ({})),
+}));
+
+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);
+ });
+});
diff --git a/server/tests/unit/services/notifications.test.ts b/server/tests/unit/services/notifications.test.ts
index 7af5706..70a9dfd 100644
--- a/server/tests/unit/services/notifications.test.ts
+++ b/server/tests/unit/services/notifications.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, afterEach } from 'vitest';
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
vi.mock('../../../src/db/database', () => ({
db: { prepare: () => ({ get: vi.fn(() => undefined), all: vi.fn(() => []) }) },
@@ -18,7 +18,15 @@ vi.mock('../../../src/services/auditLog', () => ({
vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: vi.fn() })) } }));
vi.mock('node-fetch', () => ({ default: vi.fn() }));
-import { getEventText, buildEmailHtml, buildWebhookBody } from '../../../src/services/notifications';
+// ssrfGuard is mocked per-test in the SSRF describe block; default passes all
+vi.mock('../../../src/utils/ssrfGuard', () => ({
+ checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })),
+ createPinnedAgent: vi.fn(() => ({})),
+}));
+
+import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook } from '../../../src/services/notifications';
+import { checkSsrf } from '../../../src/utils/ssrfGuard';
+import { logError } from '../../../src/services/auditLog';
afterEach(() => {
vi.unstubAllEnvs();
@@ -193,3 +201,119 @@ describe('buildEmailHtml', () => {
expect(unknown).toContain('notifications enabled in TREK');
});
});
+
+// ── SEC: XSS escaping in buildEmailHtml ──────────────────────────────────────
+
+describe('buildEmailHtml XSS prevention (SEC-016)', () => {
+ it('escapes HTML special characters in subject', () => {
+ const html = buildEmailHtml('', 'Body', 'en');
+ expect(html).not.toContain('',
+ });
+ const html = buildEmailHtml('Subject', body, 'en');
+ expect(html).not.toContain('');
+ expect(html).not.toContain(' |