feat(notifications): add unified multi-channel notification system
Introduces a fully featured notification system with three delivery
channels (in-app, email, webhook), normalized per-user/per-event/
per-channel preferences, admin-scoped notifications, scheduled trip
reminders and version update alerts.
- New notificationService.send() as the single orchestration entry point
- In-app notifications with simple/boolean/navigate types and WebSocket push
- Per-user preference matrix with normalized notification_channel_preferences table
- Admin notification preferences stored globally in app_settings
- Migration 69 normalizes legacy notification_preferences table
- Scheduler hooks for daily trip reminders and version checks
- DevNotificationsPanel for testing in dev mode
- All new tests passing, covering dispatch, preferences, migration, boolean
responses, resilience, and full API integration (NSVC, NPREF, INOTIF,
MIGR, VNOTIF, NROUTE series)
- Previous tests passing
2026-04-05 07:20:33 +08:00
/ * *
* 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 } ) ) ;
fix(security): address notification system security audit findings
- SSRF: guard sendWebhook() with checkSsrf() + createPinnedAgent() to block
requests to loopback, link-local, private network, and cloud metadata endpoints
- XSS: escape subject, body, and ctaHref in buildEmailHtml() via escapeHtml()
to prevent HTML injection through user-controlled params (actor, preview, etc.)
- Encrypt webhook URLs at rest: apply maybe_encrypt_api_key on save
(settingsService for user URLs, authService for admin URL) and decrypt_api_key
on read in getUserWebhookUrl() / getAdminWebhookUrl()
- Log failed channel dispatches: inspect Promise.allSettled() results and log
rejections via logError instead of silently dropping them
- Log admin webhook failures: replace fire-and-forget .catch(() => {}) with
.catch(err => logError(...)) and await the call
- Migration 69: guard against missing notification_preferences table on fresh installs
- Migration 70: drop the now-unused notification_preferences table
- Refactor: extract applyUserChannelPrefs() helper to deduplicate
setPreferences / setAdminPreferences logic
- Tests: add SEC-016 (XSS, 5 cases) and SEC-017 (SSRF, 6 cases) test suites;
mock ssrfGuard in notificationService tests
2026-04-05 09:36:22 +08:00
vi . mock ( '../../../src/utils/ssrfGuard' , ( ) = > ( {
checkSsrf : vi.fn ( async ( ) = > ( { allowed : true , isPrivate : false , resolvedIp : '1.2.3.4' } ) ) ,
createPinnedAgent : vi.fn ( ( ) = > ( { } ) ) ,
} ) ) ;
feat(notifications): add unified multi-channel notification system
Introduces a fully featured notification system with three delivery
channels (in-app, email, webhook), normalized per-user/per-event/
per-channel preferences, admin-scoped notifications, scheduled trip
reminders and version update alerts.
- New notificationService.send() as the single orchestration entry point
- In-app notifications with simple/boolean/navigate types and WebSocket push
- Per-user preference matrix with normalized notification_channel_preferences table
- Admin notification preferences stored globally in app_settings
- Migration 69 normalizes legacy notification_preferences table
- Scheduler hooks for daily trip reminders and version checks
- DevNotificationsPanel for testing in dev mode
- All new tests passing, covering dispatch, preferences, migration, boolean
responses, resilience, and full API integration (NSVC, NPREF, INOTIF,
MIGR, VNOTIF, NROUTE series)
- Previous tests passing
2026-04-05 07:20:33 +08:00
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 ) ;
} ) ;
} ) ;