Spaces:
Sleeping
Sleeping
| /** | |
| * 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.stubGlobal('fetch', 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' })), | |
| createPinnedDispatcher: 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(); | |
| vi.unstubAllGlobals(); | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 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); | |
| }); | |
| }); | |
| // ββ Ntfy dispatch βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function setUserNtfyTopic(userId: number, topic = 'my-trek-topic'): void { | |
| testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'ntfy_topic', ?)").run(userId, topic); | |
| } | |
| function setAdminNtfyTopic(topic = 'trek-admin-alerts'): void { | |
| setAppSetting(testDb, 'admin_ntfy_topic', topic); | |
| } | |
| describe('send() β ntfy channel dispatch', () => { | |
| beforeEach(() => { | |
| fetchMock.mockResolvedValue({ ok: true, text: async () => '' }); | |
| }); | |
| it('NTFY-SVCB-001 β ntfy fires when channel active and user has topic configured', async () => { | |
| const { user } = createUser(testDb); | |
| setUserNtfyTopic(user.id); | |
| setNotificationChannels(testDb, 'ntfy'); | |
| const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Tokyo', user.id)).lastInsertRowid as number; | |
| await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Tokyo', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); | |
| const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh')); | |
| expect(ntfyCalls.length).toBeGreaterThan(0); | |
| // Header-based API: metadata in headers, body = plain text | |
| expect(ntfyCalls[0][1].headers['Priority']).toBe('4'); // trip_invite = high priority | |
| expect(ntfyCalls[0][1].headers['Tags']).toContain('loudspeaker'); | |
| }); | |
| it('NTFY-SVCB-002 β ntfy skips when channel not in active channels', async () => { | |
| const { user } = createUser(testDb); | |
| setUserNtfyTopic(user.id); | |
| setNotificationChannels(testDb, 'none'); | |
| fetchMock.mockClear(); | |
| await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '1' } }); | |
| const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh')); | |
| expect(ntfyCalls.length).toBe(0); | |
| }); | |
| it('NTFY-SVCB-003 β ntfy skips when user has no topic configured', async () => { | |
| const { user } = createUser(testDb); | |
| setNotificationChannels(testDb, 'ntfy'); | |
| // No ntfy_topic set, but no admin_ntfy_server either β resolveNtfyUrl returns null | |
| fetchMock.mockClear(); | |
| await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } }); | |
| const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh')); | |
| expect(ntfyCalls.length).toBe(0); | |
| }); | |
| it('NTFY-SVCB-004 β admin-scoped version_available fires admin ntfy topic', async () => { | |
| createAdmin(testDb); | |
| setAdminNtfyTopic(); | |
| setNotificationChannels(testDb, 'none'); | |
| fetchMock.mockClear(); | |
| await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '3.0.0' } }); | |
| const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh')); | |
| expect(ntfyCalls.length).toBeGreaterThan(0); | |
| expect(ntfyCalls[0][1].headers['Priority']).toBe('4'); // version_available = high priority | |
| expect(ntfyCalls[0][1].headers['Tags']).toContain('package'); | |
| }); | |
| }); | |