feat(notifications): add unified multi-channel notification system
Introduces a fully featured notification system with three delivery channels (in-app, email, webhook), normalized per-user/per-event/ per-channel preferences, admin-scoped notifications, scheduled trip reminders and version update alerts. - New notificationService.send() as the single orchestration entry point - In-app notifications with simple/boolean/navigate types and WebSocket push - Per-user preference matrix with normalized notification_channel_preferences table - Admin notification preferences stored globally in app_settings - Migration 69 normalizes legacy notification_preferences table - Scheduler hooks for daily trip reminders and version checks - DevNotificationsPanel for testing in dev mode - All new tests passing, covering dispatch, preferences, migration, boolean responses, resilience, and full API integration (NSVC, NPREF, INOTIF, MIGR, VNOTIF, NROUTE series) - Previous tests passing
This commit is contained in:
294
server/tests/unit/services/inAppNotificationPrefs.test.ts
Normal file
294
server/tests/unit/services/inAppNotificationPrefs.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Unit tests for in-app notification preference filtering in createNotification().
|
||||
* Covers INOTIF-001 to INOTIF-004.
|
||||
*/
|
||||
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: () => {},
|
||||
}));
|
||||
|
||||
// Mock WebSocket broadcast — must use vi.hoisted() so broadcastMock is available
|
||||
// when the vi.mock factory is evaluated (factories are hoisted before const declarations)
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createAdmin, disableNotificationPref } from '../../helpers/factories';
|
||||
import { createNotification, createNotificationForRecipient, respondToBoolean } from '../../../src/services/inAppNotifications';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// createNotification — preference filtering
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createNotification — preference filtering', () => {
|
||||
it('INOTIF-001 — notification without event_type is delivered to all recipients (backward compat)', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const { user: recipient } = createUser(testDb);
|
||||
// The admin scope targets all admins — create a second admin as the sender
|
||||
const { user: sender } = createAdmin(testDb);
|
||||
|
||||
// Send to a specific user (user scope) without event_type
|
||||
const ids = createNotification({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: recipient.id,
|
||||
sender_id: sender.id,
|
||||
title_key: 'notifications.test.title',
|
||||
text_key: 'notifications.test.text',
|
||||
// no event_type
|
||||
});
|
||||
|
||||
expect(ids.length).toBe(1);
|
||||
const row = testDb.prepare('SELECT * FROM notifications WHERE recipient_id = ?').get(recipient.id);
|
||||
expect(row).toBeDefined();
|
||||
// Also verify the admin who disabled all prefs still gets messages without event_type
|
||||
disableNotificationPref(testDb, admin.id, 'trip_invite', 'inapp');
|
||||
// admin still gets this since no event_type check
|
||||
const adminIds = createNotification({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: admin.id,
|
||||
sender_id: sender.id,
|
||||
title_key: 'notifications.test.title',
|
||||
text_key: 'notifications.test.text',
|
||||
});
|
||||
expect(adminIds.length).toBe(1);
|
||||
});
|
||||
|
||||
it('INOTIF-002 — notification with event_type skips recipients who have disabled that event on inapp', () => {
|
||||
const { user: sender } = createAdmin(testDb);
|
||||
const { user: recipient1 } = createUser(testDb);
|
||||
const { user: recipient2 } = createUser(testDb);
|
||||
|
||||
// recipient2 has disabled inapp for trip_invite
|
||||
disableNotificationPref(testDb, recipient2.id, 'trip_invite', 'inapp');
|
||||
|
||||
// Use a trip to target both members
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Test Trip', sender.id)).lastInsertRowid as number;
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient1.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient2.id);
|
||||
|
||||
const ids = createNotification({
|
||||
type: 'simple',
|
||||
scope: 'trip',
|
||||
target: tripId,
|
||||
sender_id: sender.id,
|
||||
event_type: 'trip_invite',
|
||||
title_key: 'notifications.test.title',
|
||||
text_key: 'notifications.test.text',
|
||||
});
|
||||
|
||||
// sender excluded, recipient1 included, recipient2 skipped (disabled pref)
|
||||
expect(ids.length).toBe(1);
|
||||
const r1 = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient1.id);
|
||||
const r2 = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient2.id);
|
||||
expect(r1).toBeDefined();
|
||||
expect(r2).toBeUndefined();
|
||||
});
|
||||
|
||||
it('INOTIF-003 — notification with event_type delivers to recipients with no stored preferences', () => {
|
||||
const { user: sender } = createAdmin(testDb);
|
||||
const { user: recipient } = createUser(testDb);
|
||||
|
||||
// No preferences stored for recipient — should default to enabled
|
||||
const ids = createNotification({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: recipient.id,
|
||||
sender_id: sender.id,
|
||||
event_type: 'trip_invite',
|
||||
title_key: 'notifications.test.title',
|
||||
text_key: 'notifications.test.text',
|
||||
});
|
||||
|
||||
expect(ids.length).toBe(1);
|
||||
const row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient.id);
|
||||
expect(row).toBeDefined();
|
||||
});
|
||||
|
||||
it('INOTIF-003b — createNotificationForRecipient inserts a single notification and broadcasts via WS', () => {
|
||||
const { user: sender } = createAdmin(testDb);
|
||||
const { user: recipient } = createUser(testDb);
|
||||
|
||||
const id = createNotificationForRecipient(
|
||||
{
|
||||
type: 'navigate',
|
||||
scope: 'user',
|
||||
target: recipient.id,
|
||||
sender_id: sender.id,
|
||||
event_type: 'trip_invite',
|
||||
title_key: 'notif.trip_invite.title',
|
||||
text_key: 'notif.trip_invite.text',
|
||||
navigate_text_key: 'notif.action.view_trip',
|
||||
navigate_target: '/trips/99',
|
||||
},
|
||||
recipient.id,
|
||||
{ username: 'admin', avatar: null }
|
||||
);
|
||||
|
||||
expect(id).toBeTypeOf('number');
|
||||
const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as { recipient_id: number; navigate_target: string } | undefined;
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.recipient_id).toBe(recipient.id);
|
||||
expect(row!.navigate_target).toBe('/trips/99');
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
expect(broadcastMock.mock.calls[0][0]).toBe(recipient.id);
|
||||
});
|
||||
|
||||
it('INOTIF-004 — admin-scope version_available only reaches admins with enabled pref', () => {
|
||||
const { user: admin1 } = createAdmin(testDb);
|
||||
const { user: admin2 } = createAdmin(testDb);
|
||||
|
||||
// admin2 disables version_available inapp notifications
|
||||
disableNotificationPref(testDb, admin2.id, 'version_available', 'inapp');
|
||||
|
||||
const ids = createNotification({
|
||||
type: 'navigate',
|
||||
scope: 'admin',
|
||||
target: 0,
|
||||
sender_id: null,
|
||||
event_type: 'version_available',
|
||||
title_key: 'notifications.versionAvailable.title',
|
||||
text_key: 'notifications.versionAvailable.text',
|
||||
navigate_text_key: 'notifications.versionAvailable.button',
|
||||
navigate_target: '/admin',
|
||||
});
|
||||
|
||||
// Only admin1 should receive it
|
||||
expect(ids.length).toBe(1);
|
||||
const admin1Row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(admin1.id);
|
||||
const admin2Row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(admin2.id);
|
||||
expect(admin1Row).toBeDefined();
|
||||
expect(admin2Row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// respondToBoolean
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function insertBooleanNotification(recipientId: number, senderId: number | null = null): number {
|
||||
const result = testDb.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback
|
||||
) VALUES ('boolean', 'user', ?, ?, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
|
||||
'notif.action.accept', 'notif.action.decline',
|
||||
'{"action":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}'
|
||||
)
|
||||
`).run(recipientId, senderId, recipientId);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
function insertSimpleNotification(recipientId: number): number {
|
||||
const result = testDb.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params
|
||||
) VALUES ('simple', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}')
|
||||
`).run(recipientId, recipientId);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
describe('respondToBoolean', () => {
|
||||
it('INOTIF-005 — positive response sets response=positive, marks read, broadcasts update', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
|
||||
const result = await respondToBoolean(id, user.id, 'positive');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.notification).toBeDefined();
|
||||
const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as any;
|
||||
expect(row.response).toBe('positive');
|
||||
expect(row.is_read).toBe(1);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(user.id, expect.objectContaining({ type: 'notification:updated' }));
|
||||
});
|
||||
|
||||
it('INOTIF-006 — negative response sets response=negative', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
|
||||
const result = await respondToBoolean(id, user.id, 'negative');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT response FROM notifications WHERE id = ?').get(id) as any;
|
||||
expect(row.response).toBe('negative');
|
||||
});
|
||||
|
||||
it('INOTIF-007 — double-response prevention returns error on second call', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
|
||||
await respondToBoolean(id, user.id, 'positive');
|
||||
const result = await respondToBoolean(id, user.id, 'negative');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/already responded/i);
|
||||
});
|
||||
|
||||
it('INOTIF-008 — response on a simple notification returns error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertSimpleNotification(user.id);
|
||||
|
||||
const result = await respondToBoolean(id, user.id, 'positive');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/not a boolean/i);
|
||||
});
|
||||
|
||||
it('INOTIF-009 — response on a non-existent notification returns error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = await respondToBoolean(99999, user.id, 'positive');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('INOTIF-010 — response on notification belonging to another user returns error', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const id = insertBooleanNotification(owner.id);
|
||||
|
||||
const result = await respondToBoolean(id, other.id, 'positive');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
234
server/tests/unit/services/migration.test.ts
Normal file
234
server/tests/unit/services/migration.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Unit tests for migration 69 (normalized notification preferences).
|
||||
* Covers MIGR-001 to MIGR-004.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
|
||||
function buildFreshDb() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all migrations up to (but NOT including) migration 69, then return the db.
|
||||
* This allows us to set up old-schema data and test that migration 69 handles it.
|
||||
*
|
||||
* We do this by running only the schema tables that existed before migration 69,
|
||||
* seeding old data, then running migration 69 in isolation.
|
||||
*/
|
||||
function setupPreMigration69Db() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Create schema_version and users table (bare minimum)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0);
|
||||
INSERT INTO schema_version (version) VALUES (0);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
email TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
notify_trip_invite INTEGER DEFAULT 1,
|
||||
notify_booking_change INTEGER DEFAULT 1,
|
||||
notify_trip_reminder INTEGER DEFAULT 1,
|
||||
notify_vacay_invite INTEGER DEFAULT 1,
|
||||
notify_photos_shared INTEGER DEFAULT 1,
|
||||
notify_collab_message INTEGER DEFAULT 1,
|
||||
notify_packing_tagged INTEGER DEFAULT 1,
|
||||
notify_webhook INTEGER DEFAULT 1,
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
`);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and run only migration 69 (index 68) from the migrations array.
|
||||
* We do this by importing migrations and calling the last one directly.
|
||||
*/
|
||||
function runMigration69(db: ReturnType<typeof Database>): void {
|
||||
// Migration 69 logic extracted inline for isolation
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_channel_preferences (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (user_id, event_type, channel)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id);
|
||||
`);
|
||||
|
||||
const oldPrefs = db.prepare('SELECT * FROM notification_preferences').all() as Array<Record<string, number>>;
|
||||
const eventCols: Record<string, string> = {
|
||||
trip_invite: 'notify_trip_invite',
|
||||
booking_change: 'notify_booking_change',
|
||||
trip_reminder: 'notify_trip_reminder',
|
||||
vacay_invite: 'notify_vacay_invite',
|
||||
photos_shared: 'notify_photos_shared',
|
||||
collab_message: 'notify_collab_message',
|
||||
packing_tagged: 'notify_packing_tagged',
|
||||
};
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const insertMany = db.transaction((rows: Array<[number, string, string, number]>) => {
|
||||
for (const [userId, eventType, channel, enabled] of rows) {
|
||||
insert.run(userId, eventType, channel, enabled);
|
||||
}
|
||||
});
|
||||
|
||||
for (const row of oldPrefs) {
|
||||
const userId = row.user_id as number;
|
||||
const webhookEnabled = (row.notify_webhook as number) ?? 0;
|
||||
const rows: Array<[number, string, string, number]> = [];
|
||||
for (const [eventType, col] of Object.entries(eventCols)) {
|
||||
const emailEnabled = (row[col] as number) ?? 1;
|
||||
if (!emailEnabled) rows.push([userId, eventType, 'email', 0]);
|
||||
if (!webhookEnabled) rows.push([userId, eventType, 'webhook', 0]);
|
||||
}
|
||||
if (rows.length > 0) insertMany(rows);
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO app_settings (key, value)
|
||||
SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel';
|
||||
`);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Migration 69 tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Migration 69 — normalized notification_channel_preferences', () => {
|
||||
it('MIGR-001 — notification_channel_preferences table exists after migration', () => {
|
||||
const db = setupPreMigration69Db();
|
||||
runMigration69(db);
|
||||
|
||||
const table = db.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name='notification_channel_preferences'`
|
||||
).get();
|
||||
expect(table).toBeDefined();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('MIGR-002 — old notification_preferences rows with disabled events migrated as enabled=0', () => {
|
||||
const db = setupPreMigration69Db();
|
||||
|
||||
// Create a user
|
||||
const userId = (db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('testuser', 'hash', 'user')).lastInsertRowid as number;
|
||||
|
||||
// Simulate user who has disabled trip_invite and booking_change email
|
||||
db.prepare(`
|
||||
INSERT INTO notification_preferences
|
||||
(user_id, notify_trip_invite, notify_booking_change, notify_trip_reminder,
|
||||
notify_vacay_invite, notify_photos_shared, notify_collab_message, notify_packing_tagged, notify_webhook)
|
||||
VALUES (?, 0, 0, 1, 1, 1, 1, 1, 1)
|
||||
`).run(userId);
|
||||
|
||||
runMigration69(db);
|
||||
|
||||
const tripInviteEmail = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, 'trip_invite', 'email') as { enabled: number } | undefined;
|
||||
const bookingEmail = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, 'booking_change', 'email') as { enabled: number } | undefined;
|
||||
const reminderEmail = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, 'trip_reminder', 'email') as { enabled: number } | undefined;
|
||||
|
||||
// Disabled events should have enabled=0 rows
|
||||
expect(tripInviteEmail).toBeDefined();
|
||||
expect(tripInviteEmail!.enabled).toBe(0);
|
||||
expect(bookingEmail).toBeDefined();
|
||||
expect(bookingEmail!.enabled).toBe(0);
|
||||
// Enabled events should have no row (no-row = enabled)
|
||||
expect(reminderEmail).toBeUndefined();
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('MIGR-003 — old notify_webhook=0 creates disabled webhook rows for all 7 events', () => {
|
||||
const db = setupPreMigration69Db();
|
||||
|
||||
const userId = (db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('webhookuser', 'hash', 'user')).lastInsertRowid as number;
|
||||
|
||||
// User has all email enabled but webhook disabled
|
||||
db.prepare(`
|
||||
INSERT INTO notification_preferences
|
||||
(user_id, notify_trip_invite, notify_booking_change, notify_trip_reminder,
|
||||
notify_vacay_invite, notify_photos_shared, notify_collab_message, notify_packing_tagged, notify_webhook)
|
||||
VALUES (?, 1, 1, 1, 1, 1, 1, 1, 0)
|
||||
`).run(userId);
|
||||
|
||||
runMigration69(db);
|
||||
|
||||
const allEvents = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'];
|
||||
for (const eventType of allEvents) {
|
||||
const row = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, eventType, 'webhook') as { enabled: number } | undefined;
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.enabled).toBe(0);
|
||||
|
||||
// Email rows should NOT exist (all email was enabled → no row needed)
|
||||
const emailRow = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, eventType, 'email');
|
||||
expect(emailRow).toBeUndefined();
|
||||
}
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('MIGR-004 — notification_channels key is created in app_settings from notification_channel value', () => {
|
||||
const db = setupPreMigration69Db();
|
||||
|
||||
// Simulate existing single-channel setting
|
||||
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channel', 'email');
|
||||
|
||||
runMigration69(db);
|
||||
|
||||
const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as { value: string } | undefined;
|
||||
expect(plural).toBeDefined();
|
||||
expect(plural!.value).toBe('email');
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('MIGR-004b — notification_channels is not duplicated if already exists', () => {
|
||||
const db = setupPreMigration69Db();
|
||||
|
||||
// Both keys already set (e.g. partial migration or manual edit)
|
||||
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channel', 'email');
|
||||
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channels', 'email,webhook');
|
||||
|
||||
runMigration69(db);
|
||||
|
||||
// The existing notification_channels value should be preserved (INSERT OR IGNORE)
|
||||
const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as { value: string } | undefined;
|
||||
expect(plural!.value).toBe('email,webhook');
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 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/services/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 {
|
||||
isEnabledForEvent,
|
||||
getPreferencesMatrix,
|
||||
setPreferences,
|
||||
setAdminPreferences,
|
||||
getAdminGlobalPref,
|
||||
getActiveChannels,
|
||||
getAvailableChannels,
|
||||
} from '../../../src/services/notificationPreferencesService';
|
||||
|
||||
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');
|
||||
expect(event_types.length).toBe(7);
|
||||
});
|
||||
|
||||
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(7);
|
||||
});
|
||||
|
||||
it('NPREF-005b — admin scope returns only version_available', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const { event_types } = getPreferencesMatrix(user.id, 'admin', 'admin');
|
||||
expect(event_types).toContain('version_available');
|
||||
expect(event_types.length).toBe(1);
|
||||
});
|
||||
|
||||
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 — available_channels.inapp is always true', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { available_channels } = getPreferencesMatrix(user.id, 'user');
|
||||
expect(available_channels.inapp).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-009 — available_channels.email is true when email is in notification_channels', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'email');
|
||||
const { available_channels } = getPreferencesMatrix(user.id, 'user');
|
||||
expect(available_channels.email).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-010 — available_channels.email is false when email is not in notification_channels', () => {
|
||||
const { user } = createUser(testDb);
|
||||
// No notification_channels set → defaults to none
|
||||
const { available_channels } = getPreferencesMatrix(user.id, 'user');
|
||||
expect(available_channels.email).toBe(false);
|
||||
});
|
||||
|
||||
it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook]', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const { implemented_combos } = getPreferencesMatrix(user.id, 'admin', 'admin');
|
||||
expect(implemented_combos['version_available']).toEqual(['inapp', 'email', 'webhook']);
|
||||
// All events now support all three channels
|
||||
expect(implemented_combos['trip_invite']).toContain('inapp');
|
||||
expect(implemented_combos['trip_invite']).toContain('email');
|
||||
expect(implemented_combos['trip_invite']).toContain('webhook');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// getAvailableChannels
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getAvailableChannels', () => {
|
||||
it('NPREF-019 — detects SMTP config from app_settings.smtp_host', () => {
|
||||
setAppSetting(testDb, 'smtp_host', 'mail.example.com');
|
||||
const channels = getAvailableChannels();
|
||||
expect(channels.email).toBe(true);
|
||||
expect(channels.inapp).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-020 — webhook available when admin has enabled the webhook channel', () => {
|
||||
setNotificationChannels(testDb, 'webhook');
|
||||
const channels = getAvailableChannels();
|
||||
expect(channels.webhook).toBe(true);
|
||||
});
|
||||
|
||||
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 {
|
||||
const channels = getAvailableChannels();
|
||||
expect(channels.email).toBe(true);
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.SMTP_HOST;
|
||||
else process.env.SMTP_HOST = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
455
server/tests/unit/services/notificationService.test.ts
Normal file
455
server/tests/unit/services/notificationService.test.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* 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.mock('node-fetch', () => ({ default: fetchMock }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock }));
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
173
server/tests/unit/services/versionNotification.test.ts
Normal file
173
server/tests/unit/services/versionNotification.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Unit tests for checkAndNotifyVersion() in adminService.
|
||||
* Covers VNOTIF-001 to VNOTIF-007.
|
||||
*/
|
||||
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/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||||
// Mock MCP to avoid session side-effects
|
||||
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createAdmin } from '../../helpers/factories';
|
||||
import { checkAndNotifyVersion } from '../../../src/services/adminService';
|
||||
|
||||
// Helper: mock the GitHub releases/latest endpoint
|
||||
function mockGitHubLatest(tagName: string, ok = true): void {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
json: async () => ({ tag_name: tagName, html_url: `https://github.com/mauriceboe/TREK/releases/tag/${tagName}` }),
|
||||
}));
|
||||
}
|
||||
|
||||
function mockGitHubFetchFailure(): void {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||||
}
|
||||
|
||||
function getLastNotifiedVersion(): string | undefined {
|
||||
return (testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as { value: string } | undefined)?.value;
|
||||
}
|
||||
|
||||
function getNotificationCount(): number {
|
||||
return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// checkAndNotifyVersion
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('checkAndNotifyVersion', () => {
|
||||
it('VNOTIF-001 — does nothing when no update is available', async () => {
|
||||
createAdmin(testDb);
|
||||
// GitHub reports same version as package.json (or older) → update_available: false
|
||||
const { version } = require('../../../package.json');
|
||||
mockGitHubLatest(`v${version}`);
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
expect(getNotificationCount()).toBe(0);
|
||||
expect(getLastNotifiedVersion()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('VNOTIF-002 — creates a navigate notification for all admins when update available', async () => {
|
||||
const { user: admin1 } = createAdmin(testDb);
|
||||
const { user: admin2 } = createAdmin(testDb);
|
||||
mockGitHubLatest('v99.0.0');
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
const notifications = testDb.prepare('SELECT * FROM notifications ORDER BY id').all() as Array<{ recipient_id: number; type: string; scope: string }>;
|
||||
expect(notifications.length).toBe(2);
|
||||
const recipientIds = notifications.map(n => n.recipient_id);
|
||||
expect(recipientIds).toContain(admin1.id);
|
||||
expect(recipientIds).toContain(admin2.id);
|
||||
expect(notifications[0].type).toBe('navigate');
|
||||
expect(notifications[0].scope).toBe('admin');
|
||||
});
|
||||
|
||||
it('VNOTIF-003 — sets last_notified_version in app_settings after notifying', async () => {
|
||||
createAdmin(testDb);
|
||||
mockGitHubLatest('v99.1.0');
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
expect(getLastNotifiedVersion()).toBe('99.1.0');
|
||||
});
|
||||
|
||||
it('VNOTIF-004 — does NOT create duplicate notification if last_notified_version matches', async () => {
|
||||
createAdmin(testDb);
|
||||
mockGitHubLatest('v99.2.0');
|
||||
|
||||
// First call notifies
|
||||
await checkAndNotifyVersion();
|
||||
const countAfterFirst = getNotificationCount();
|
||||
expect(countAfterFirst).toBe(1);
|
||||
|
||||
// Second call with same version — should not create another
|
||||
await checkAndNotifyVersion();
|
||||
expect(getNotificationCount()).toBe(countAfterFirst);
|
||||
});
|
||||
|
||||
it('VNOTIF-005 — creates new notification when last_notified_version is an older version', async () => {
|
||||
createAdmin(testDb);
|
||||
// Simulate having been notified about an older version
|
||||
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('last_notified_version', '98.0.0');
|
||||
mockGitHubLatest('v99.3.0');
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
expect(getNotificationCount()).toBe(1);
|
||||
expect(getLastNotifiedVersion()).toBe('99.3.0');
|
||||
});
|
||||
|
||||
it('VNOTIF-006 — notification has correct type, scope, and navigate_target', async () => {
|
||||
createAdmin(testDb);
|
||||
mockGitHubLatest('v99.4.0');
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
const notif = testDb.prepare('SELECT * FROM notifications LIMIT 1').get() as {
|
||||
type: string;
|
||||
scope: string;
|
||||
navigate_target: string;
|
||||
title_key: string;
|
||||
text_key: string;
|
||||
navigate_text_key: string;
|
||||
};
|
||||
expect(notif.type).toBe('navigate');
|
||||
expect(notif.scope).toBe('admin');
|
||||
expect(notif.navigate_target).toBe('/admin');
|
||||
expect(notif.title_key).toBe('notif.version_available.title');
|
||||
expect(notif.text_key).toBe('notif.version_available.text');
|
||||
expect(notif.navigate_text_key).toBe('notif.action.view_admin');
|
||||
});
|
||||
|
||||
it('VNOTIF-007 — silently handles GitHub API fetch failure (no crash, no notification)', async () => {
|
||||
createAdmin(testDb);
|
||||
mockGitHubFetchFailure();
|
||||
|
||||
// Should not throw
|
||||
await expect(checkAndNotifyVersion()).resolves.toBeUndefined();
|
||||
expect(getNotificationCount()).toBe(0);
|
||||
expect(getLastNotifiedVersion()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user