Files
TREK/server/tests/unit/nest/notification-preferences.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

453 lines
23 KiB
TypeScript

/**
* Unit tests for notificationPreferencesService.
* Covers NPREF-001 to NPREF-021.
*/
import { db as testDb } from '../../../src/db/database';
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity';
import { __resetChannelsForTest } from '../../../src/nest/notifications/channel-registry';
import { registerBuiltinChannels } from '../../../src/nest/notifications/channels/builtins';
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
import { NotificationPreferencesService } from '../../../src/nest/notifications/notification-preferences.service';
import { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service';
import { WebhookService } from '../../../src/nest/notifications/transports/webhook.service';
import {
createUser,
createAdmin,
setAppSetting,
setNotificationChannels,
disableNotificationPref,
} from '../../helpers/factories';
import { countRows, findRow, insertRow } from '../../helpers/factories/rows';
import { makeWebPushService } from '../../helpers/notifications';
import { createTestNotificationChannelPreferencesRepo } from '../../helpers/notifications-repos';
import { resetTestDb } from '../../helpers/test-db';
import {
createTestUnitOfWork,
createTestAppSettingsRepo,
createTestSettingsRepo,
createTestUsersRepo,
sharedTestOrm,
} from '../../helpers/test-uow';
import { describe, it, expect, vi, beforeAll, beforeEach, 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,
}));
/** The stored per-user preference row, or null when the cell is at its default. */
async function storedPref(userId: number, eventType: string, channel: string) {
return findRow(await sharedTestOrm(testDb), NotificationChannelPreferences, {
user: userId,
event_type: eventType,
channel,
});
}
/** The stored app setting row, or null. */
async function storedAppSetting(key: string) {
return findRow(await sharedTestOrm(testDb), AppSettings, { key });
}
// Built in beforeAll: MailerService/WebhookService/NtfyService now take
// repositories, resolved async through test-uow.ts's memoised factories.
let mailer: MailerService;
// Constructed in beforeAll: the service now takes a UnitOfWork, which is async to build.
let svc: NotificationPreferencesService;
// Legacy free-function names bound to the service, so the moved cases read as before.
// Arrow forwarders rather than `.bind(svc)`: under `strictBindCallApply: false` a bound
// alias is typed `any`, which hides a missing `await` from tsc AND from the type-aware
// lint rules (recipe R4).
type Svc = NotificationPreferencesService;
const isEnabledForEvent = (...a: Parameters<Svc['isEnabledForEvent']>) => svc.isEnabledForEvent(...a);
const getPreferencesMatrix = (...a: Parameters<Svc['getPreferencesMatrix']>) => svc.getPreferencesMatrix(...a);
const setPreferences = (...a: Parameters<Svc['setPreferences']>) => svc.setPreferences(...a);
const setAdminPreferences = (...a: Parameters<Svc['setAdminPreferences']>) => svc.setAdminPreferences(...a);
const getAdminGlobalPref = (...a: Parameters<Svc['getAdminGlobalPref']>) => svc.getAdminGlobalPref(...a);
const getActiveChannels = (...a: Parameters<Svc['getActiveChannels']>) => svc.getActiveChannels(...a);
const isSmtpConfigured = (...a: Parameters<Svc['isSmtpConfigured']>) => svc.isSmtpConfigured(...a);
const isWebhookConfigured = (...a: Parameters<Svc['isWebhookConfigured']>) => svc.isWebhookConfigured(...a);
beforeAll(async () => {
const usersRepo = await createTestUsersRepo(testDb);
const settingsRepo = await createTestSettingsRepo(testDb);
const appSettingsRepoForChannels = await createTestAppSettingsRepo(testDb);
mailer = new MailerService(usersRepo, settingsRepo, appSettingsRepoForChannels);
registerBuiltinChannels({
mailer,
webhook: new WebhookService(settingsRepo, appSettingsRepoForChannels),
ntfy: new NtfyService(settingsRepo, appSettingsRepoForChannels),
push: await makeWebPushService(testDb),
});
svc = new NotificationPreferencesService(
mailer,
await createTestUnitOfWork(testDb),
await createTestAppSettingsRepo(testDb),
await createTestNotificationChannelPreferencesRepo(testDb),
);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// isEnabledForEvent
// ─────────────────────────────────────────────────────────────────────────────
describe('isEnabledForEvent', () => {
it('NPREF-001 — returns true when no row exists (default enabled)', async () => {
const { user } = createUser(testDb);
expect(await isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
});
it('NPREF-002 — returns true when row exists with enabled=1', async () => {
const { user } = createUser(testDb);
await insertRow(await sharedTestOrm(testDb), NotificationChannelPreferences, {
user: user.id,
event_type: 'trip_invite',
channel: 'email',
enabled: 1,
});
expect(await isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
});
it('NPREF-003 — returns false when row exists with enabled=0', async () => {
const { user } = createUser(testDb);
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
expect(await isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// getPreferencesMatrix
// ─────────────────────────────────────────────────────────────────────────────
describe('getPreferencesMatrix', () => {
it('NPREF-004 — regular user does not see version_available in event_types', async () => {
const { user } = createUser(testDb);
const { event_types } = await getPreferencesMatrix(user.id, 'user');
expect(event_types).not.toContain('version_available');
// +1 for plugin_notification: users can mute host-mediated plugin notifications.
// +1 for wanderer_source_hint: the hints of the user's own wanderer watch (WAN-02).
expect(event_types.length).toBe(13);
});
it('NPREF-005 — user scope excludes version_available for everyone including admins', async () => {
const { user } = createAdmin(testDb);
const { event_types } = await getPreferencesMatrix(user.id, 'admin', 'user');
expect(event_types).not.toContain('version_available');
expect(event_types.length).toBe(13);
});
it('NPREF-005b — admin scope returns the admin-scoped events', async () => {
const { user } = createAdmin(testDb);
const { event_types } = await getPreferencesMatrix(user.id, 'admin', 'admin');
expect(event_types).toContain('version_available');
expect(event_types).toContain('replica_failure');
expect(event_types).toContain('paddle_region_requested');
expect(event_types.length).toBe(3);
});
it('NPREF-006 — returns default true for all preferences when no stored prefs', async () => {
const { user } = createUser(testDb);
const { preferences } = await getPreferencesMatrix(user.id, 'user');
for (const [, channels] of Object.entries(preferences)) {
for (const [, enabled] of Object.entries(channels as Record<string, boolean>)) {
expect(enabled).toBe(true);
}
}
});
it('NPREF-007 — reflects stored disabled preferences in the matrix', async () => {
const { user } = createUser(testDb);
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
disableNotificationPref(testDb, user.id, 'collab_message', 'webhook');
const { preferences } = await getPreferencesMatrix(user.id, 'user');
expect(preferences['trip_invite']!['email']).toBe(false);
expect(preferences['collab_message']!['webhook']).toBe(false);
// Others unaffected
expect(preferences['trip_invite']!['webhook']).toBe(true);
expect(preferences['booking_change']!['email']).toBe(true);
});
it('NPREF-008 — the inapp channel is always active', async () => {
const { user } = createUser(testDb);
const { channels } = await getPreferencesMatrix(user.id, 'user');
expect(channels.find((c) => c.id === 'inapp')?.active).toBe(true);
});
it('NPREF-009 — email is active when email is in notification_channels', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'email');
const { channels } = await getPreferencesMatrix(user.id, 'user');
expect(channels.find((c) => c.id === 'email')?.active).toBe(true);
});
it('NPREF-010 — email is inactive when email is not in notification_channels', async () => {
const { user } = createUser(testDb);
// No notification_channels set → defaults to none
const { channels } = await getPreferencesMatrix(user.id, 'user');
expect(channels.find((c) => c.id === 'email')?.active).toBe(false);
});
it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook, ntfy]', async () => {
const { user } = createAdmin(testDb);
const { implemented_combos } = await getPreferencesMatrix(user.id, 'admin', 'admin');
expect(implemented_combos['version_available']).toEqual(['inapp', 'email', 'webhook', 'ntfy']);
// All events now support all four channels
expect(implemented_combos['trip_invite']).toContain('inapp');
expect(implemented_combos['trip_invite']).toContain('email');
expect(implemented_combos['trip_invite']).toContain('webhook');
expect(implemented_combos['trip_invite']).toContain('ntfy');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// setPreferences
// ─────────────────────────────────────────────────────────────────────────────
describe('setPreferences', () => {
it('NPREF-012 — disabling a preference inserts a row with enabled=0', async () => {
const { user } = createUser(testDb);
await setPreferences(user.id, { trip_invite: { email: false } });
const row = await storedPref(user.id, 'trip_invite', 'email');
expect(row).not.toBeNull();
expect(row!.enabled).toBe(0);
});
it('NPREF-013 — re-enabling a preference removes the disabled row', async () => {
const { user } = createUser(testDb);
// First disable
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
// Then re-enable
await setPreferences(user.id, { trip_invite: { email: true } });
const row = await storedPref(user.id, 'trip_invite', 'email');
// Row should be deleted — default is enabled
expect(row).toBeNull();
});
it('NPREF-014 — bulk update handles multiple event+channel combos', async () => {
const { user } = createUser(testDb);
await setPreferences(user.id, {
trip_invite: { email: false, webhook: false },
booking_change: { email: false },
trip_reminder: { webhook: true },
});
expect(await isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
expect(await isEnabledForEvent(user.id, 'trip_invite', 'webhook')).toBe(false);
expect(await isEnabledForEvent(user.id, 'booking_change', 'email')).toBe(false);
// trip_reminder webhook was set to true → no row, default enabled
expect(await storedPref(user.id, 'trip_reminder', 'webhook')).toBeNull();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// getActiveChannels
// ─────────────────────────────────────────────────────────────────────────────
describe('getActiveChannels', () => {
it('NPREF-015 — returns [] when notification_channels is none', async () => {
setAppSetting(testDb, 'notification_channels', 'none');
expect(await getActiveChannels()).toEqual([]);
});
it('NPREF-016 — returns [email] when notification_channels is email', async () => {
setAppSetting(testDb, 'notification_channels', 'email');
expect(await getActiveChannels()).toEqual(['email']);
});
it('NPREF-017 — returns [email, webhook] when notification_channels is email,webhook', async () => {
setAppSetting(testDb, 'notification_channels', 'email,webhook');
expect(await getActiveChannels()).toEqual(['email', 'webhook']);
});
it('NPREF-018 — falls back to notification_channel (singular) when plural key absent', async () => {
// Only set the singular key
setAppSetting(testDb, 'notification_channel', 'webhook');
// No notification_channels key
expect(await getActiveChannels()).toEqual(['webhook']);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// SMTP / active-channel detection
// ─────────────────────────────────────────────────────────────────────────────
describe('channel availability', () => {
it('NPREF-019 — detects SMTP config from app_settings.smtp_host', async () => {
setAppSetting(testDb, 'smtp_host', 'mail.example.com');
expect(await isSmtpConfigured()).toBe(true);
});
it('NPREF-020 — webhook is active when admin has enabled the webhook channel', async () => {
setNotificationChannels(testDb, 'webhook');
expect(await getActiveChannels()).toContain('webhook');
});
it('NPREF-021 — detects SMTP config from env var SMTP_HOST', async () => {
const original = process.env.SMTP_HOST;
process.env.SMTP_HOST = 'env-mail.example.com';
try {
expect(await isSmtpConfigured()).toBe(true);
} finally {
if (original === undefined) delete process.env.SMTP_HOST;
else process.env.SMTP_HOST = original;
}
});
it('NPREF-022 — an unknown channel id in notification_channels is ignored', async () => {
// e.g. a plugin channel left in the CSV after the plugin was uninstalled
setNotificationChannels(testDb, 'webhook,plugin:long-gone');
expect(await getActiveChannels()).toEqual(['webhook']);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// setAdminPreferences
// ─────────────────────────────────────────────────────────────────────────────
describe('setAdminPreferences', () => {
it('NPREF-022 — disabling email for version_available stores global pref in app_settings', async () => {
const { user } = createAdmin(testDb);
await setAdminPreferences(user.id, { version_available: { email: false } });
expect(await getAdminGlobalPref('version_available', 'email')).toBe(false);
const row = await storedAppSetting('admin_notif_pref_version_available_email');
expect(row?.value).toBe('0');
});
it('NPREF-023 — disabling inapp for version_available stores per-user row in notification_channel_preferences', async () => {
const { user } = createAdmin(testDb);
await setAdminPreferences(user.id, { version_available: { inapp: false } });
const row = await storedPref(user.id, 'version_available', 'inapp');
expect(row).not.toBeNull();
expect(row!.enabled).toBe(0);
// Global app_settings should NOT have an inapp key
expect(await storedAppSetting('admin_notif_pref_version_available_inapp')).toBeNull();
});
it('NPREF-024 — re-enabling inapp removes the disabled per-user row', async () => {
const { user } = createAdmin(testDb);
// First disable
disableNotificationPref(testDb, user.id, 'version_available', 'inapp');
// Then re-enable via setAdminPreferences
await setAdminPreferences(user.id, { version_available: { inapp: true } });
expect(await storedPref(user.id, 'version_available', 'inapp')).toBeNull();
});
it('NPREF-025 — enabling email stores global pref as "1" in app_settings', async () => {
const { user } = createAdmin(testDb);
// First disable, then re-enable
await setAdminPreferences(user.id, { version_available: { email: false } });
await setAdminPreferences(user.id, { version_available: { email: true } });
expect(await getAdminGlobalPref('version_available', 'email')).toBe(true);
const row = await storedAppSetting('admin_notif_pref_version_available_email');
expect(row?.value).toBe('1');
});
it('NPREF-025b: the global and the per-user rows are one save: a failing in-app row keeps the email pref unchanged', async () => {
const { user } = createAdmin(testDb);
const prefsRepo = await createTestNotificationChannelPreferencesRepo(testDb);
const spy = vi.spyOn(prefsRepo, 'upsertPreference').mockRejectedValueOnce(new Error('disk full'));
await expect(setAdminPreferences(user.id, { version_available: { email: false, inapp: false } })).rejects.toThrow(
'disk full',
);
expect(await storedAppSetting('admin_notif_pref_version_available_email')).toBeNull();
spy.mockRestore();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// isWebhookConfigured
// ─────────────────────────────────────────────────────────────────────────────
describe('isWebhookConfigured', () => {
it('NPREF-026 — returns false when webhook is not in active channels', async () => {
// No notification_channels configured → defaults don't include webhook
expect(await isWebhookConfigured()).toBe(false);
});
it('NPREF-027 — returns true when webhook is in active channels', async () => {
setNotificationChannels(testDb, 'webhook');
expect(await isWebhookConfigured()).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Instance defaults (#1536)
// ─────────────────────────────────────────────────────────────────────────────
describe('instance defaults', () => {
it('NPREF-028 — "off" as the default turns a cell off for everyone who never touched it', async () => {
const { user } = createUser(testDb);
await svc.setInstanceDefaults({ trip_invite: { email: 'off' } });
expect(await isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
expect((await getPreferencesMatrix(user.id, 'user')).preferences.trip_invite?.email).toBe(false);
// Other cells keep the old default.
expect(await isEnabledForEvent(user.id, 'trip_invite', 'inapp')).toBe(true);
});
it('NPREF-029 — a user may still turn an "off" default on, and that choice is kept', async () => {
const { user } = createUser(testDb);
await svc.setInstanceDefaults({ trip_invite: { email: 'off' } });
await setPreferences(user.id, { trip_invite: { email: true } });
expect(await isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
// Switching back to the default leaves no row behind.
await setPreferences(user.id, { trip_invite: { email: false } });
expect(await countRows(await sharedTestOrm(testDb), NotificationChannelPreferences, { user: user.id })).toBe(0);
});
it('NPREF-030 — a blocked cell is off, locked in the matrix, and a user cannot turn it on', async () => {
const { user } = createUser(testDb);
await svc.setInstanceDefaults({ booking_change: { email: 'blocked' } });
await setPreferences(user.id, { booking_change: { email: true } });
expect(await isEnabledForEvent(user.id, 'booking_change', 'email')).toBe(false);
const matrix = await getPreferencesMatrix(user.id, 'user');
expect(matrix.preferences.booking_change?.email).toBe(false);
expect(matrix.locked?.booking_change).toEqual(['email']);
});
it('NPREF-031 — admin-scoped events and unknown cells are never touched by defaults', async () => {
const { user: admin } = createAdmin(testDb);
await svc.setInstanceDefaults({
version_available: { inapp: 'blocked' },
trip_invite: { carrier_pigeon: 'off' },
} as never);
expect(await isEnabledForEvent(admin.id, 'version_available', 'inapp')).toBe(true);
expect(await countRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'notif_default_%' } })).toBe(0);
});
it('NPREF-032 — the defaults matrix lists user events only, "on" stores nothing', async () => {
const { user: admin } = createAdmin(testDb);
await svc.setInstanceDefaults({ trip_invite: { email: 'off' } });
const before = await svc.getInstanceDefaults(admin.id);
expect(before.event_types).not.toContain('version_available');
expect(before.defaults.trip_invite?.email).toBe('off');
expect(before.defaults.trip_invite?.inapp).toBe('on');
await svc.setInstanceDefaults({ trip_invite: { email: 'on' } });
expect(await countRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'notif_default_%' } })).toBe(0);
});
});