Files
TREK/server/tests/unit/nest/notifications.service.test.ts
T
Maurice 2e1dca9fe9 feat(tours): let a background check hint at changes of your own wanderer trails
Settings, Integrations, wanderer gains an optional background check of the
trails you imported, off until you switch it on. While on, TREK reads the
short record of each such trail at most once a day (never its GPX file) and
leaves a hint when the trail may have moved on: "may have changed" when only
wanderer's change time moved, "changed" when its name or description proves
it, and "not checkable" when the trail is gone, no longer yours or carries no
change time. Nothing is ever said to be unchanged. A new hint arrives as a
notification that opens the tour (a new ?place= link in the planner); the
tour's provenance shows it with the source check right beside it, and the
card lists all open hints in a dialog, each leading to its tour. The check
reads the source in full as before and spends the hint; taking a change in
stays the confirmed reimport. No tour is ever changed or deleted.

The check only asks through your own connection, only about trails your
account imported into trips you can still open, and stops when you switch it
off, remove the connection, wanderer refuses the token (until a test passes
or a new token is saved) or the admin switches wanderer off; hints and
snapshots stay. A run asks at most 25 connections for at most 10 records
each, one after another, and an instance that fails (an outage, a rate
limit) is left alone for an hour, doubling up to two days.

Server: migration 046120 adds the switch and the run bookkeeping to
wanderer_connections and the tour_source_hints table (one row per snapshot,
for its importer, gone with it). WandererWatchService and an hourly
wanderer-watch job on the CronRegistrar live in the tours domain and read
through WandererService; GET and PUT /api/integrations/wanderer/watch and GET
/api/trips/:tripId/tours/wanderer/sources/:placeId/hint (place_edit, like the
check) answer with @trek/shared contracts. A new user notification event,
wanderer_source_hint, can be muted per channel. Saving a new token now clears
what the last check said about the old one. There is no MCP tool, as for the
rest of the personal wanderer connection. All 27 locales carry the strings,
and the admin wiki page says what the check does.
2026-10-20 15:35:32 +02:00

1118 lines
42 KiB
TypeScript

/**
* Unit tests for the DI-native NotificationsService.send() — NSVC-001 through
* NSVC-019, NTFY-SVCB-*, NSVC-PLUG-001..007 (moved 1:1 from the legacy
* tests/unit/services/notificationService.test.ts when the send dispatcher +
* in-app SQL folded into nest/notifications), plus the NSVC-020 bridge
* delegation pin. Uses a real in-memory SQLite DB so the SQL is exercised
* faithfully; email/webhook/ntfy transports are mocked at the nodemailer/fetch
* boundary.
*/
import { db as testDb } from '../../../src/db/database';
import { Notifications } from '../../../src/db/entities/Notifications.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { setPluginChannelSource } from '../../../src/nest/notifications/channel-registry';
// The channel interface lives in notification-events; channel-registry only imports
// it for its own signatures and never re-exported it.
import type { ExternalChannel } from '../../../src/nest/notifications/notification-events';
import { NotificationsService, type NotificationPayload } from '../../../src/nest/notifications/notifications.service';
import {
createUser,
createAdmin,
setAppSetting,
setNotificationChannels,
disableNotificationPref,
} from '../../helpers/factories';
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
import { setUserSetting } from '../../helpers/factories/settings';
import { addTripMember, makeTrip } from '../../helpers/factories/trips';
import { FakeRealtimeService } from '../../helpers/fake-realtime';
import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications';
import { resetTestDb } from '../../helpers/test-db';
import { sharedTestOrm } from '../../helpers/test-uow';
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
return mock;
});
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
decrypt_api_key: (v: string | null) => v,
maybe_encrypt_api_key: (v: string) => v,
encrypt_api_key: (v: string) => v,
}));
const { sendMailMock, fetchMock, logErrorMock } = vi.hoisted(() => ({
sendMailMock: vi.fn().mockResolvedValue({ accepted: ['test@test.com'] }),
fetchMock: vi.fn(),
logErrorMock: vi.fn(),
}));
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: logErrorMock,
logWarn: vi.fn(),
}));
vi.mock('nodemailer', () => ({
default: {
createTransport: vi.fn(() => ({
sendMail: sendMailMock,
verify: vi.fn().mockResolvedValue(true),
})),
},
}));
vi.stubGlobal('fetch', fetchMock);
vi.mock('../../../src/utils/ssrfGuard', () => {
class SsrfBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SsrfBlockedError';
}
}
const checkSsrf = vi.fn(async (_url: string) => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' }));
return {
checkSsrf,
createPinnedDispatcher: vi.fn(() => ({})),
SsrfBlockedError,
// The notification transports go through safeFetchFollow now, so the fake
// has to guard and then hand over to the stubbed fetch the way it does.
safeFetchFollow: vi.fn(async (url: string, init?: RequestInit) => {
const verdict = await checkSsrf(url);
if (!verdict.allowed) {
throw new SsrfBlockedError((verdict as { error?: string }).error ?? 'Request blocked by SSRF guard');
}
return fetch(url, init);
}),
};
});
const realtime = new FakeRealtimeService();
const broadcastMock = realtime.broadcastToUserMock;
// Built in beforeAll: the service now takes a UnitOfWork, which is async to build.
let notifications: NotificationsService;
const send = (payload: NotificationPayload) => notifications.send(payload);
// ── Helpers ────────────────────────────────────────────────────────────────
function setSmtp(): void {
setAppSetting(testDb, 'smtp_host', 'mail.test.com');
setAppSetting(testDb, 'smtp_port', '587');
setAppSetting(testDb, 'smtp_from', 'trek@test.com');
}
const orm = () => sharedTestOrm(testDb);
async function setUserWebhookUrl(userId: number, url = 'https://hooks.test.com/webhook'): Promise<void> {
await setUserSetting(await orm(), userId, 'webhook_url', url);
}
function setAdminWebhookUrl(url = 'https://hooks.test.com/admin-webhook'): void {
setAppSetting(testDb, 'admin_webhook_url', url);
}
async function getInAppNotifications(recipientId: number) {
return findRows(await orm(), Notifications, { recipient: recipientId }, { id: 'asc' });
}
async function countAllNotifications(): Promise<number> {
return countRows(await orm(), Notifications);
}
/** Every notification's recipient, lowest id first. */
async function recipientIds(): Promise<number[]> {
return (await findRows(await orm(), Notifications, {}, { recipient: 'asc' })).map((r) => r.recipient_id);
}
/** A trip with just its owner, returning its id. */
async function newTrip(title: string, userId: number): Promise<number> {
return (await makeTrip(await orm(), userId, { title })).id;
}
/** Points the user's email at the address the mail assertions expect. */
async function setRecipientEmail(userId: number): Promise<void> {
await updateRows(await orm(), Users, { id: userId }, { email: 'recipient@test.com' });
}
// ── Setup ──────────────────────────────────────────────────────────────────
beforeAll(async () => {
notifications = await makeNotificationsService(testDb, realtime);
});
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();
await setUserWebhookUrl(user.id);
setNotificationChannels(testDb, 'email,webhook');
await setRecipientEmail(user.id);
const tripId = await newTrip('Paris', user.id);
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(await countAllNotifications()).toBe(1);
});
it('NSVC-002 — skips email/webhook when no channels are active (in-app still fires)', async () => {
const { user } = createUser(testDb);
setSmtp();
await setUserWebhookUrl(user.id);
setNotificationChannels(testDb, 'none');
const tripId = await newTrip('Rome', user.id);
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(await countAllNotifications()).toBe(1);
});
it('NSVC-003 — sends only email when only email channel is active', async () => {
const { user } = createUser(testDb);
setSmtp();
setNotificationChannels(testDb, 'email');
await setRecipientEmail(user.id);
const tripId = await newTrip('Berlin', user.id);
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');
await setRecipientEmail(user.id);
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
const tripId = await newTrip('Paris', user.id);
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 = await newTrip('Trip', user.id);
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(await countAllNotifications()).toBe(0);
});
it('NSVC-006 — still sends webhook when user has email disabled but webhook enabled', async () => {
const { user } = createUser(testDb);
setSmtp();
await setUserWebhookUrl(user.id);
setNotificationChannels(testDb, 'email,webhook');
await setRecipientEmail(user.id);
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
const tripId = await newTrip('Paris', user.id);
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 = await newTrip('Trip', owner.id);
await addTripMember(await orm(), tripId, member1.id);
await addTripMember(await orm(), tripId, member2.id);
await addTripMember(await orm(), 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(await countAllNotifications()).toBe(3);
const recipients = await recipientIds();
expect(recipients).toContain(owner.id);
expect(recipients).toContain(member1.id);
expect(recipients).toContain(member2.id);
expect(recipients).not.toContain(actor.id);
});
it('NSVC-007b — guests are never notified, on trip or user scope (#1362)', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
setNotificationChannels(testDb, 'none');
const tripId = await newTrip('Trip', owner.id);
await addTripMember(await orm(), tripId, member.id);
// A guest joined into the trip — assignable, but has no inbox.
const guestId = await insertRow(await orm(), Users, {
username: 'Guest',
email: 'guest-x@guests.invalid',
password_hash: '',
role: 'user',
is_guest: 1,
});
await addTripMember(await orm(), tripId, guestId);
await send({
event: 'booking_change',
actorId: owner.id,
scope: 'trip',
targetId: tripId,
params: { trip: 'Trip', actor: 'Owner', booking: 'Hotel', type: 'hotel', tripId: String(tripId) },
});
let recipients = await recipientIds();
expect(recipients).toContain(member.id);
expect(recipients).not.toContain(guestId);
// Even a direct user-scope notification (e.g. a todo assigned to the guest) is dropped.
await send({
event: 'vacay_invite',
actorId: owner.id,
scope: 'user',
targetId: guestId,
params: { actor: 'owner@test.com', planId: '1' },
});
recipients = await recipientIds();
expect(recipients).not.toContain(guestId);
});
it('NSVC-007c — guest-exclusion (#1362), the direct call: resolveRecipients itself never returns a guest for trip or user scope (Plan 3f Task 3 — the NT2 chokepoint, R8)', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const tripId = await newTrip('Trip', owner.id);
await addTripMember(await orm(), tripId, member.id);
const guestId = await insertRow(await orm(), Users, {
username: 'Guest',
email: 'guest-y@guests.invalid',
password_hash: '',
role: 'user',
is_guest: 1,
});
await addTripMember(await orm(), tripId, guestId);
const tripRecipients = await notifications.resolveRecipients('trip', tripId);
expect(tripRecipients).toContain(owner.id);
expect(tripRecipients).toContain(member.id);
expect(tripRecipients).not.toContain(guestId);
const userRecipients = await notifications.resolveRecipients('user', guestId);
expect(userRecipients).toEqual([]);
});
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(await countAllNotifications()).toBe(1);
const notif = (await findRow(await orm(), Notifications, {}))!;
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(await countAllNotifications()).toBe(2);
const recipients = await recipientIds();
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 = await newTrip('Solo', owner.id);
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(await 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 = await 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 = await 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 = await 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');
});
it('NOTIF-RF-002 suppressed > 0 stores the textSuppressed key; 0 keeps the plain key', async () => {
const { user: admin } = createAdmin(testDb);
setNotificationChannels(testDb, 'none');
await send({
event: 'replica_failure',
actorId: null,
scope: 'admin',
targetId: 0,
params: { backend: 'minio', op: 'put', key: 'trips/1/a.jpg', error: 'ECONNRESET', suppressed: '3' },
});
await send({
event: 'replica_failure',
actorId: null,
scope: 'admin',
targetId: 0,
params: { backend: 'minio', op: 'put', key: 'trips/1/b.jpg', error: 'ECONNRESET', suppressed: '0' },
});
const notifs = await getInAppNotifications(admin.id);
expect(notifs.length).toBe(2);
expect(notifs[0].text_key).toBe('notif.replica_failure.textSuppressed');
expect(notifs[1].text_key).toBe('notif.replica_failure.text');
});
it('NOTIF-RF-003 a missing suppressed param is NOT treated as suppressed (truthy guard, not just !== "0")', async () => {
const { user: admin } = createAdmin(testDb);
setNotificationChannels(testDb, 'none');
await send({
event: 'replica_failure',
actorId: null,
scope: 'admin',
targetId: 0,
params: { backend: 'minio', op: 'put', key: 'trips/1/c.jpg', error: 'ECONNRESET' },
});
const notifs = await getInAppNotifications(admin.id);
expect(notifs.length).toBe(1);
expect(notifs[0].text_key).toBe('notif.replica_failure.text');
});
it('NOTIF-WAN-001 a wanderer hint names its state in the text and leads to its tour (WAN-02)', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'none');
const hint = (state: string, placeId: string) =>
send({
event: 'wanderer_source_hint',
actorId: null,
scope: 'user',
targetId: user.id,
params: { state, tour: 'Ridge', trip: 'Alps', tripId: '5', placeId },
});
await hint('possible_change', '11');
await hint('changed', '12');
await hint('unverifiable', '13');
await hint('changed', '');
const notifs = await getInAppNotifications(user.id);
expect(notifs.map((n) => [n.text_key, n.navigate_target])).toEqual(
expect.arrayContaining([
['notif.wanderer_source_hint.possible_change', '/trips/5?tab=plan&place=11'],
['notif.wanderer_source_hint.changed', '/trips/5?tab=plan&place=12'],
['notif.wanderer_source_hint.unverifiable', '/trips/5?tab=plan&place=13'],
['notif.wanderer_source_hint.changed', null],
]),
);
expect(notifs.every((n) => n.title_key === 'notif.wanderer_source_hint.title')).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 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');
await setRecipientEmail(user.id);
// Set user language to French
await setUserSetting(await orm(), user.id, 'language', 'fr');
const tripId = await newTrip('Paris', user.id);
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);
await 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 = await 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();
await setUserWebhookUrl(user.id);
setNotificationChannels(testDb, 'email,webhook');
await setRecipientEmail(user.id);
// Make email throw
sendMailMock.mockRejectedValueOnce(new Error('SMTP connection refused'));
const tripId = await newTrip('Trip', user.id);
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(await countAllNotifications()).toBe(1);
});
it('NSVC-019 — webhook failure does not prevent in-app or email delivery', async () => {
const { user } = createUser(testDb);
setSmtp();
await setUserWebhookUrl(user.id);
setNotificationChannels(testDb, 'email,webhook');
await setRecipientEmail(user.id);
// Make webhook throw
fetchMock.mockRejectedValueOnce(new Error('Network error'));
const tripId = await newTrip('Trip', user.id);
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(await countAllNotifications()).toBe(1);
});
});
describe('send() reports what it delivered', () => {
it('NSVC-022: counts in-app and every channel that went out', async () => {
const { user } = createUser(testDb);
setSmtp();
await setUserWebhookUrl(user.id);
setNotificationChannels(testDb, 'email,webhook');
await setRecipientEmail(user.id);
const tripId = await newTrip('Trip', user.id);
await expect(
send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) },
}),
).resolves.toEqual({ attempted: 3, delivered: 3 });
});
it('NSVC-023: a channel that fails is attempted but not delivered', async () => {
const { user } = createUser(testDb);
setSmtp();
setNotificationChannels(testDb, 'email');
await setRecipientEmail(user.id);
disableNotificationPref(testDb, user.id, 'trip_reminder', 'inapp');
sendMailMock.mockRejectedValueOnce(new Error('SMTP connection refused'));
const tripId = await newTrip('Trip', user.id);
await expect(
send({
event: 'trip_reminder',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Trip', tripId: String(tripId) },
}),
).resolves.toEqual({ attempted: 1, delivered: 0 });
});
it('NSVC-024: nothing attempted when there is nobody to tell or nobody wants it', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'none');
disableNotificationPref(testDb, user.id, 'collab_message', 'inapp');
const tripId = await newTrip('Trip', user.id);
await expect(
send({
event: 'collab_message',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Trip', actor: 'Alice', tripId: String(tripId) },
}),
).resolves.toEqual({ attempted: 0, delivered: 0 });
await expect(
send({
event: 'booking_change',
actorId: user.id,
scope: 'trip',
targetId: tripId,
params: { trip: 'Trip', actor: 'a', booking: 'Hotel', type: 'hotel', tripId: String(tripId) },
}),
).resolves.toEqual({ attempted: 0, delivered: 0 });
});
});
// ── Ntfy dispatch ─────────────────────────────────────────────────────────────
async function setUserNtfyTopic(userId: number, topic = 'my-trek-topic'): Promise<void> {
await setUserSetting(await orm(), userId, 'ntfy_topic', 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);
await setUserNtfyTopic(user.id);
setNotificationChannels(testDb, 'ntfy');
const tripId = await newTrip('Tokyo', user.id);
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);
await 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 — resolveNtfyUrl requires a user topic, so it 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-005 — ntfy does not fall back to admin topic when user has no topic (#1608)', async () => {
const { user } = createUser(testDb);
setAdminNtfyTopic();
setNotificationChannels(testDb, 'ntfy');
fetchMock.mockClear();
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Oslo', 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');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Plugin notification channels (hook:notification-channel)
// ─────────────────────────────────────────────────────────────────────────────
describe('send() — plugin notification channels', () => {
const sendSpy = vi.fn();
function installPluginChannel(over: Partial<ExternalChannel> = {}): void {
sendSpy.mockClear();
sendSpy.mockResolvedValue(undefined);
setPluginChannelSource(() => [
{
id: 'plugin:gotify',
source: 'plugin',
label: 'Gotify',
supportsEvent: (e: string) => e !== 'version_available' && e !== 'synology_session_cleared',
isConfiguredFor: () => true,
sendToUser: sendSpy,
...over,
} as ExternalChannel,
]);
}
afterEach(() => setPluginChannelSource(null));
it('NSVC-PLUG-001 — delivers to a plugin channel the admin enabled', async () => {
const { user } = createUser(testDb);
installPluginChannel();
setNotificationChannels(testDb, 'plugin:gotify');
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' },
});
expect(sendSpy).toHaveBeenCalledTimes(1);
const [recipientId, msg] = sendSpy.mock.calls[0];
expect(recipientId).toBe(user.id);
// The host renders the text — a channel plugin never touches i18n.
expect(msg.event).toBe('trip_invite');
expect(msg.title).toBeTruthy();
expect(msg.body).toContain('Rome');
});
it('NSVC-PLUG-002 — delivers with NOTHING in notification_channels: enabling the plugin IS the opt-in', async () => {
const { user } = createUser(testDb);
installPluginChannel();
setNotificationChannels(testDb, 'none');
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' },
});
// A built-in always exists in the code, so it needs an explicit switch. A plugin
// channel only exists because an admin installed and enabled that plugin — and
// nothing can write a `plugin:` id into this CSV anyway, so requiring a second
// opt-in meant the channel could never be turned on at all.
expect(sendSpy).toHaveBeenCalledTimes(1);
});
it('NSVC-PLUG-003 — skipped when the user opted out of this event on it', async () => {
const { user } = createUser(testDb);
installPluginChannel();
setNotificationChannels(testDb, 'plugin:gotify');
disableNotificationPref(testDb, user.id, 'trip_invite', 'plugin:gotify');
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' },
});
expect(sendSpy).not.toHaveBeenCalled();
});
it('NSVC-PLUG-004 — skipped when the user has not set their credentials', async () => {
const { user } = createUser(testDb);
installPluginChannel({ isConfiguredFor: () => false });
setNotificationChannels(testDb, 'plugin:gotify');
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' },
});
expect(sendSpy).not.toHaveBeenCalled();
});
it('NSVC-PLUG-005 — a throwing plugin channel does not stop in-app or email delivery', async () => {
const { user } = createUser(testDb);
installPluginChannel({ sendToUser: vi.fn().mockRejectedValue(new Error('gotify is down')) });
setSmtp();
setNotificationChannels(testDb, 'email,plugin:gotify');
sendMailMock.mockClear();
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' },
});
expect(sendMailMock).toHaveBeenCalledTimes(1);
expect((await getInAppNotifications(user.id)).length).toBe(1);
});
it('NSVC-PLUG-006 — never receives an admin-scoped event', async () => {
createAdmin(testDb);
// Even if a (malicious) channel claims to support it, ADMIN_SCOPED_EVENTS is
// gated host-side: only a channel that bypasses the toggle (email) delivers those.
installPluginChannel({ supportsEvent: () => true });
setNotificationChannels(testDb, 'plugin:gotify');
await send({
event: 'version_available',
actorId: null,
scope: 'admin',
targetId: 0,
params: { version: '3.0.0' },
});
expect(sendSpy).not.toHaveBeenCalled();
});
it('NSVC-PLUG-007 — a channel that narrows its events only gets those', async () => {
const { user } = createUser(testDb);
installPluginChannel({ supportsEvent: (e: string) => e === 'booking_change' });
setNotificationChannels(testDb, 'plugin:gotify');
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' },
});
expect(sendSpy).not.toHaveBeenCalled();
await send({
event: 'booking_change',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Rome', actor: 'Alice', tripId: '1' },
});
expect(sendSpy).toHaveBeenCalledTimes(1);
});
it('NSVC-021 — a rejected channel dispatch logs the unwrapped Error message (fix-commit pin)', async () => {
const { user } = createUser(testDb);
installPluginChannel({ sendToUser: vi.fn().mockRejectedValue(new Error('gotify is down')) });
setNotificationChannels(testDb, 'plugin:gotify');
logErrorMock.mockClear();
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' },
});
const dispatchLog = logErrorMock.mock.calls
.map(([msg]) => String(msg))
.find((m) => m.includes('channel dispatch failed'));
// The legacy per-recipient log interpolated the raw reason ("Error: gotify
// is down"); the admin path always unwrapped — now both do.
expect(dispatchLog).toContain(': gotify is down');
expect(dispatchLog).not.toContain('Error: gotify is down');
});
});
// NSVC-020 pinned notifications.instance.ts, which died with the last
// cycle-dodge bridge — every consumer injects the container singleton now.