Replace node-fetch v2 with Node 22's built-in fetch API across the entire server.
Add undici as an explicit dependency to provide the dispatcher API needed for
DNS pinning (SSRF rebinding prevention) in ssrfGuard.ts. All seven service files
that used a plain `import fetch from 'node-fetch'` are updated to use the global.
The ssrfGuard safeFetch/createPinnedAgent is rewritten as createPinnedDispatcher
using an undici Agent, with correct handling of the `all: true` lookup callback
required by Node 18+. The collabService dynamic require() and notifications agent
option are updated to use the dispatcher pattern. Test mocks are migrated from
vi.mock('node-fetch') to vi.stubGlobal('fetch'), and streaming test fixtures are
updated to use Web ReadableStream instead of Node Readable.
Fix several bugs in the Synology and Immich photo integrations:
- pipeAsset: guard against setting headers after stream has already started
- _getSynologySession: clear stale SID and re-login when decrypt_api_key returns null
instead of propagating success(null) downstream
- _requestSynologyApi: return retrySession error (not stale session) on retry failure;
also retry on error codes 106 (timeout) and 107 (duplicate login), not only 119
- searchSynologyPhotos: fix incorrect total field type (Synology list_item returns no
total); hasMore correctly uses allItems.length === limit
- _splitPackedSynologyId: validate cache_key format before use; callers return 400
- getImmichCredentials / _getSynologyCredentials: treat null from decrypt_api_key as
a missing-credentials condition rather than casting null to string
- Synology size param: enforce allowlist ['sm', 'm', 'xl'] per API documentation
461 lines
22 KiB
TypeScript
461 lines
22 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|