mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
f4626c9f3e
Admin > Notifications gets a Defaults for users matrix: each event and channel starts On, Off (a user may still turn it on) or Blocked (off for everyone). A user's own choice is kept against the default, and a value equal to it is stored as no row, so a later change of the default reaches everyone who never chose. Blocked cells show as locked in the user settings on desktop and phone, and the server ignores any attempt to turn one on. (#1536)
413 lines
21 KiB
TypeScript
413 lines
21 KiB
TypeScript
/**
|
|
* Unit tests for notificationPreferencesService.
|
|
* Covers NPREF-001 to NPREF-021.
|
|
*/
|
|
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/nest/common/crypto/apiKeyCrypto', () => ({
|
|
decrypt_api_key: (v: string | null) => v,
|
|
maybe_encrypt_api_key: (v: string) => v,
|
|
encrypt_api_key: (v: string) => v,
|
|
}));
|
|
|
|
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 { DatabaseService } from '../../../src/nest/database/database.service';
|
|
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
|
|
import { NotificationPreferencesService } from '../../../src/nest/notifications/notification-preferences.service';
|
|
import { registerBuiltinChannels } from '../../../src/nest/notifications/channels/builtins';
|
|
import { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service';
|
|
import { WebhookService } from '../../../src/nest/notifications/transports/webhook.service';
|
|
import { __resetChannelsForTest } from '../../../src/nest/notifications/channel-registry';
|
|
import { makeWebPushService } from '../../helpers/notifications';
|
|
|
|
const dbs = new DatabaseService(testDb);
|
|
const mailer = new MailerService(dbs);
|
|
registerBuiltinChannels({
|
|
mailer,
|
|
webhook: new WebhookService(dbs),
|
|
ntfy: new NtfyService(dbs),
|
|
push: makeWebPushService(dbs),
|
|
});
|
|
const svc = new NotificationPreferencesService(dbs, mailer);
|
|
|
|
// Legacy free-function names bound to the service, so the moved cases read as before.
|
|
const isEnabledForEvent = svc.isEnabledForEvent.bind(svc);
|
|
const getPreferencesMatrix = svc.getPreferencesMatrix.bind(svc);
|
|
const setPreferences = svc.setPreferences.bind(svc);
|
|
const setAdminPreferences = svc.setAdminPreferences.bind(svc);
|
|
const getAdminGlobalPref = svc.getAdminGlobalPref.bind(svc);
|
|
const getActiveChannels = svc.getActiveChannels.bind(svc);
|
|
const isSmtpConfigured = svc.isSmtpConfigured.bind(svc);
|
|
const isWebhookConfigured = svc.isWebhookConfigured.bind(svc);
|
|
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// isEnabledForEvent
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('isEnabledForEvent', () => {
|
|
it('NPREF-001 — returns true when no row exists (default enabled)', () => {
|
|
const { user } = createUser(testDb);
|
|
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
|
|
});
|
|
|
|
it('NPREF-002 — returns true when row exists with enabled=1', () => {
|
|
const { user } = createUser(testDb);
|
|
testDb.prepare(
|
|
'INSERT INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 1)'
|
|
).run(user.id, 'trip_invite', 'email');
|
|
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
|
|
});
|
|
|
|
it('NPREF-003 — returns false when row exists with enabled=0', () => {
|
|
const { user } = createUser(testDb);
|
|
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
|
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// getPreferencesMatrix
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('getPreferencesMatrix', () => {
|
|
it('NPREF-004 — regular user does not see version_available in event_types', () => {
|
|
const { user } = createUser(testDb);
|
|
const { event_types } = getPreferencesMatrix(user.id, 'user');
|
|
expect(event_types).not.toContain('version_available');
|
|
// +1 for plugin_notification: users can mute host-mediated plugin notifications.
|
|
expect(event_types.length).toBe(12);
|
|
});
|
|
|
|
it('NPREF-005 — user scope excludes version_available for everyone including admins', () => {
|
|
const { user } = createAdmin(testDb);
|
|
const { event_types } = getPreferencesMatrix(user.id, 'admin', 'user');
|
|
expect(event_types).not.toContain('version_available');
|
|
expect(event_types.length).toBe(12);
|
|
});
|
|
|
|
it('NPREF-005b — admin scope returns the admin-scoped events', () => {
|
|
const { user } = createAdmin(testDb);
|
|
const { event_types } = getPreferencesMatrix(user.id, 'admin', 'admin');
|
|
expect(event_types).toContain('version_available');
|
|
expect(event_types).toContain('replica_failure');
|
|
expect(event_types.length).toBe(2);
|
|
});
|
|
|
|
it('NPREF-006 — returns default true for all preferences when no stored prefs', () => {
|
|
const { user } = createUser(testDb);
|
|
const { preferences } = 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', () => {
|
|
const { user } = createUser(testDb);
|
|
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
|
disableNotificationPref(testDb, user.id, 'collab_message', 'webhook');
|
|
const { preferences } = 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', () => {
|
|
const { user } = createUser(testDb);
|
|
const { channels } = 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', () => {
|
|
const { user } = createUser(testDb);
|
|
setNotificationChannels(testDb, 'email');
|
|
const { channels } = 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', () => {
|
|
const { user } = createUser(testDb);
|
|
// No notification_channels set → defaults to none
|
|
const { channels } = 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]', () => {
|
|
const { user } = createAdmin(testDb);
|
|
const { implemented_combos } = 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', () => {
|
|
const { user } = createUser(testDb);
|
|
setPreferences(user.id, { trip_invite: { email: false } });
|
|
const row = testDb.prepare(
|
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
|
).get(user.id, 'trip_invite', 'email') as { enabled: number } | undefined;
|
|
expect(row).toBeDefined();
|
|
expect(row!.enabled).toBe(0);
|
|
});
|
|
|
|
it('NPREF-013 — re-enabling a preference removes the disabled row', () => {
|
|
const { user } = createUser(testDb);
|
|
// First disable
|
|
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
|
// Then re-enable
|
|
setPreferences(user.id, { trip_invite: { email: true } });
|
|
const row = testDb.prepare(
|
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
|
).get(user.id, 'trip_invite', 'email');
|
|
// Row should be deleted — default is enabled
|
|
expect(row).toBeUndefined();
|
|
});
|
|
|
|
it('NPREF-014 — bulk update handles multiple event+channel combos', () => {
|
|
const { user } = createUser(testDb);
|
|
setPreferences(user.id, {
|
|
trip_invite: { email: false, webhook: false },
|
|
booking_change: { email: false },
|
|
trip_reminder: { webhook: true },
|
|
});
|
|
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
|
|
expect(isEnabledForEvent(user.id, 'trip_invite', 'webhook')).toBe(false);
|
|
expect(isEnabledForEvent(user.id, 'booking_change', 'email')).toBe(false);
|
|
// trip_reminder webhook was set to true → no row, default enabled
|
|
const row = testDb.prepare(
|
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
|
).get(user.id, 'trip_reminder', 'webhook');
|
|
expect(row).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// getActiveChannels
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('getActiveChannels', () => {
|
|
it('NPREF-015 — returns [] when notification_channels is none', () => {
|
|
setAppSetting(testDb, 'notification_channels', 'none');
|
|
expect(getActiveChannels()).toEqual([]);
|
|
});
|
|
|
|
it('NPREF-016 — returns [email] when notification_channels is email', () => {
|
|
setAppSetting(testDb, 'notification_channels', 'email');
|
|
expect(getActiveChannels()).toEqual(['email']);
|
|
});
|
|
|
|
it('NPREF-017 — returns [email, webhook] when notification_channels is email,webhook', () => {
|
|
setAppSetting(testDb, 'notification_channels', 'email,webhook');
|
|
expect(getActiveChannels()).toEqual(['email', 'webhook']);
|
|
});
|
|
|
|
it('NPREF-018 — falls back to notification_channel (singular) when plural key absent', () => {
|
|
// Only set the singular key
|
|
setAppSetting(testDb, 'notification_channel', 'webhook');
|
|
// No notification_channels key
|
|
expect(getActiveChannels()).toEqual(['webhook']);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// SMTP / active-channel detection
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('channel availability', () => {
|
|
it('NPREF-019 — detects SMTP config from app_settings.smtp_host', () => {
|
|
setAppSetting(testDb, 'smtp_host', 'mail.example.com');
|
|
expect(isSmtpConfigured()).toBe(true);
|
|
});
|
|
|
|
it('NPREF-020 — webhook is active when admin has enabled the webhook channel', () => {
|
|
setNotificationChannels(testDb, 'webhook');
|
|
expect(getActiveChannels()).toContain('webhook');
|
|
});
|
|
|
|
it('NPREF-021 — detects SMTP config from env var SMTP_HOST', () => {
|
|
const original = process.env.SMTP_HOST;
|
|
process.env.SMTP_HOST = 'env-mail.example.com';
|
|
try {
|
|
expect(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', () => {
|
|
// e.g. a plugin channel left in the CSV after the plugin was uninstalled
|
|
setNotificationChannels(testDb, 'webhook,plugin:long-gone');
|
|
expect(getActiveChannels()).toEqual(['webhook']);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// setAdminPreferences
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('setAdminPreferences', () => {
|
|
it('NPREF-022 — disabling email for version_available stores global pref in app_settings', () => {
|
|
const { user } = createAdmin(testDb);
|
|
setAdminPreferences(user.id, { version_available: { email: false } });
|
|
expect(getAdminGlobalPref('version_available', 'email')).toBe(false);
|
|
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined;
|
|
expect(row?.value).toBe('0');
|
|
});
|
|
|
|
it('NPREF-023 — disabling inapp for version_available stores per-user row in notification_channel_preferences', () => {
|
|
const { user } = createAdmin(testDb);
|
|
setAdminPreferences(user.id, { version_available: { inapp: false } });
|
|
const row = testDb.prepare(
|
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
|
).get(user.id, 'version_available', 'inapp') as { enabled: number } | undefined;
|
|
expect(row).toBeDefined();
|
|
expect(row!.enabled).toBe(0);
|
|
// Global app_settings should NOT have an inapp key
|
|
const globalRow = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_inapp');
|
|
expect(globalRow).toBeUndefined();
|
|
});
|
|
|
|
it('NPREF-024 — re-enabling inapp removes the disabled per-user row', () => {
|
|
const { user } = createAdmin(testDb);
|
|
// First disable
|
|
disableNotificationPref(testDb, user.id, 'version_available', 'inapp');
|
|
// Then re-enable via setAdminPreferences
|
|
setAdminPreferences(user.id, { version_available: { inapp: true } });
|
|
const row = testDb.prepare(
|
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
|
).get(user.id, 'version_available', 'inapp');
|
|
expect(row).toBeUndefined();
|
|
});
|
|
|
|
it('NPREF-025 — enabling email stores global pref as "1" in app_settings', () => {
|
|
const { user } = createAdmin(testDb);
|
|
// First disable, then re-enable
|
|
setAdminPreferences(user.id, { version_available: { email: false } });
|
|
setAdminPreferences(user.id, { version_available: { email: true } });
|
|
expect(getAdminGlobalPref('version_available', 'email')).toBe(true);
|
|
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined;
|
|
expect(row?.value).toBe('1');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// isWebhookConfigured
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('isWebhookConfigured', () => {
|
|
it('NPREF-026 — returns false when webhook is not in active channels', () => {
|
|
// No notification_channels configured → defaults don't include webhook
|
|
expect(isWebhookConfigured()).toBe(false);
|
|
});
|
|
|
|
it('NPREF-027 — returns true when webhook is in active channels', () => {
|
|
setNotificationChannels(testDb, 'webhook');
|
|
expect(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', () => {
|
|
const { user } = createUser(testDb);
|
|
svc.setInstanceDefaults({ trip_invite: { email: 'off' } });
|
|
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
|
|
expect(getPreferencesMatrix(user.id, 'user').preferences.trip_invite?.email).toBe(false);
|
|
// Other cells keep the old default.
|
|
expect(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', () => {
|
|
const { user } = createUser(testDb);
|
|
svc.setInstanceDefaults({ trip_invite: { email: 'off' } });
|
|
setPreferences(user.id, { trip_invite: { email: true } });
|
|
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
|
|
// Switching back to the default leaves no row behind.
|
|
setPreferences(user.id, { trip_invite: { email: false } });
|
|
expect(testDb.prepare('SELECT COUNT(*) AS n FROM notification_channel_preferences WHERE user_id = ?').get(user.id)).toEqual({ n: 0 });
|
|
});
|
|
|
|
it('NPREF-030 — a blocked cell is off, locked in the matrix, and a user cannot turn it on', () => {
|
|
const { user } = createUser(testDb);
|
|
svc.setInstanceDefaults({ booking_change: { email: 'blocked' } });
|
|
setPreferences(user.id, { booking_change: { email: true } });
|
|
expect(isEnabledForEvent(user.id, 'booking_change', 'email')).toBe(false);
|
|
const matrix = 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', () => {
|
|
const { user: admin } = createAdmin(testDb);
|
|
svc.setInstanceDefaults({ version_available: { inapp: 'blocked' }, trip_invite: { carrier_pigeon: 'off' } } as never);
|
|
expect(isEnabledForEvent(admin.id, 'version_available', 'inapp')).toBe(true);
|
|
expect(testDb.prepare("SELECT COUNT(*) AS n FROM app_settings WHERE key LIKE 'notif_default_%'").get()).toEqual({ n: 0 });
|
|
});
|
|
|
|
it('NPREF-032 — the defaults matrix lists user events only, "on" stores nothing', () => {
|
|
const { user: admin } = createAdmin(testDb);
|
|
svc.setInstanceDefaults({ trip_invite: { email: 'off' } });
|
|
const before = 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');
|
|
svc.setInstanceDefaults({ trip_invite: { email: 'on' } });
|
|
expect(testDb.prepare("SELECT COUNT(*) AS n FROM app_settings WHERE key LIKE 'notif_default_%'").get()).toEqual({ n: 0 });
|
|
});
|
|
});
|