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
509 lines
18 KiB
TypeScript
509 lines
18 KiB
TypeScript
/**
|
|
* Test data factories.
|
|
* Each factory inserts a row into the provided in-memory DB and returns the created object.
|
|
* Passwords are stored as bcrypt hashes (cost factor 4 for speed in tests).
|
|
*/
|
|
|
|
import Database from 'better-sqlite3';
|
|
import bcrypt from 'bcryptjs';
|
|
import { encryptMfaSecret } from '../../src/services/mfaCrypto';
|
|
|
|
let _userSeq = 0;
|
|
let _tripSeq = 0;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Users
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TestUser {
|
|
id: number;
|
|
username: string;
|
|
email: string;
|
|
role: 'admin' | 'user';
|
|
password_hash: string;
|
|
}
|
|
|
|
export function createUser(
|
|
db: Database.Database,
|
|
overrides: Partial<{ username: string; email: string; password: string; role: 'admin' | 'user' }> = {}
|
|
): { user: TestUser; password: string } {
|
|
_userSeq++;
|
|
const password = overrides.password ?? `TestPass${_userSeq}!`;
|
|
const email = overrides.email ?? `user${_userSeq}@test.example.com`;
|
|
const username = overrides.username ?? `testuser${_userSeq}`;
|
|
const role = overrides.role ?? 'user';
|
|
const hash = bcrypt.hashSync(password, 4); // cost 4 for test speed
|
|
|
|
const result = db.prepare(
|
|
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
|
|
).run(username, email, hash, role);
|
|
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid) as TestUser;
|
|
return { user, password };
|
|
}
|
|
|
|
export function createAdmin(
|
|
db: Database.Database,
|
|
overrides: Partial<{ username: string; email: string; password: string }> = {}
|
|
): { user: TestUser; password: string } {
|
|
return createUser(db, { ...overrides, role: 'admin' });
|
|
}
|
|
|
|
/**
|
|
* Creates a user with MFA already enabled (directly in DB, bypasses rate-limited HTTP endpoints).
|
|
* Returns the user, password, and the TOTP secret so tests can generate valid codes.
|
|
*/
|
|
const KNOWN_MFA_SECRET = 'JBSWY3DPEHPK3PXP'; // fixed base32 secret for deterministic tests
|
|
export function createUserWithMfa(
|
|
db: Database.Database,
|
|
overrides: Partial<{ username: string; email: string; password: string; role: 'admin' | 'user' }> = {}
|
|
): { user: TestUser; password: string; totpSecret: string } {
|
|
const { user, password } = createUser(db, overrides);
|
|
const encryptedSecret = encryptMfaSecret(KNOWN_MFA_SECRET);
|
|
db.prepare(
|
|
'UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?'
|
|
).run(encryptedSecret, user.id);
|
|
const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as TestUser;
|
|
return { user: updated, password, totpSecret: KNOWN_MFA_SECRET };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trips
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TestTrip {
|
|
id: number;
|
|
user_id: number;
|
|
title: string;
|
|
start_date: string | null;
|
|
end_date: string | null;
|
|
}
|
|
|
|
export function createTrip(
|
|
db: Database.Database,
|
|
userId: number,
|
|
overrides: Partial<{ title: string; start_date: string; end_date: string; description: string }> = {}
|
|
): TestTrip {
|
|
_tripSeq++;
|
|
const title = overrides.title ?? `Test Trip ${_tripSeq}`;
|
|
const result = db.prepare(
|
|
'INSERT INTO trips (user_id, title, description, start_date, end_date) VALUES (?, ?, ?, ?, ?)'
|
|
).run(userId, title, overrides.description ?? null, overrides.start_date ?? null, overrides.end_date ?? null);
|
|
|
|
// Auto-generate days if dates are provided
|
|
if (overrides.start_date && overrides.end_date) {
|
|
const start = new Date(overrides.start_date);
|
|
const end = new Date(overrides.end_date);
|
|
const tripId = result.lastInsertRowid as number;
|
|
let dayNumber = 1;
|
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
|
const dateStr = d.toISOString().slice(0, 10);
|
|
db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(tripId, dayNumber++, dateStr);
|
|
}
|
|
}
|
|
|
|
return db.prepare('SELECT * FROM trips WHERE id = ?').get(result.lastInsertRowid) as TestTrip;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Days
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TestDay {
|
|
id: number;
|
|
trip_id: number;
|
|
day_number: number;
|
|
date: string | null;
|
|
title: string | null;
|
|
}
|
|
|
|
export function createDay(
|
|
db: Database.Database,
|
|
tripId: number,
|
|
overrides: Partial<{ date: string; title: string; day_number: number }> = {}
|
|
): TestDay {
|
|
// Find the next day_number for this trip if not provided
|
|
const maxDay = db.prepare('SELECT MAX(day_number) as max FROM days WHERE trip_id = ?').get(tripId) as { max: number | null };
|
|
const dayNumber = overrides.day_number ?? (maxDay.max ?? 0) + 1;
|
|
const result = db.prepare(
|
|
'INSERT INTO days (trip_id, day_number, date, title) VALUES (?, ?, ?, ?)'
|
|
).run(tripId, dayNumber, overrides.date ?? null, overrides.title ?? null);
|
|
return db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid) as TestDay;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Places
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TestPlace {
|
|
id: number;
|
|
trip_id: number;
|
|
name: string;
|
|
lat: number | null;
|
|
lng: number | null;
|
|
category_id: number | null;
|
|
}
|
|
|
|
export function createPlace(
|
|
db: Database.Database,
|
|
tripId: number,
|
|
overrides: Partial<{ name: string; lat: number; lng: number; category_id: number; description: string }> = {}
|
|
): TestPlace {
|
|
// Get first available category if none provided
|
|
const defaultCat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
|
|
const categoryId = overrides.category_id ?? defaultCat?.id ?? null;
|
|
|
|
const result = db.prepare(
|
|
'INSERT INTO places (trip_id, name, lat, lng, category_id, description) VALUES (?, ?, ?, ?, ?, ?)'
|
|
).run(
|
|
tripId,
|
|
overrides.name ?? 'Test Place',
|
|
overrides.lat ?? 48.8566,
|
|
overrides.lng ?? 2.3522,
|
|
categoryId,
|
|
overrides.description ?? null
|
|
);
|
|
return db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid) as TestPlace;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trip Members
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function addTripMember(db: Database.Database, tripId: number, userId: number): void {
|
|
db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, userId);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Budget Items
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TestBudgetItem {
|
|
id: number;
|
|
trip_id: number;
|
|
name: string;
|
|
category: string;
|
|
total_price: number;
|
|
}
|
|
|
|
export function createBudgetItem(
|
|
db: Database.Database,
|
|
tripId: number,
|
|
overrides: Partial<{ name: string; category: string; total_price: number }> = {}
|
|
): TestBudgetItem {
|
|
const result = db.prepare(
|
|
'INSERT INTO budget_items (trip_id, name, category, total_price) VALUES (?, ?, ?, ?)'
|
|
).run(
|
|
tripId,
|
|
overrides.name ?? 'Test Budget Item',
|
|
overrides.category ?? 'Transport',
|
|
overrides.total_price ?? 100
|
|
);
|
|
return db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as TestBudgetItem;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Packing Items
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TestPackingItem {
|
|
id: number;
|
|
trip_id: number;
|
|
name: string;
|
|
category: string;
|
|
checked: number;
|
|
}
|
|
|
|
export function createPackingItem(
|
|
db: Database.Database,
|
|
tripId: number,
|
|
overrides: Partial<{ name: string; category: string }> = {}
|
|
): TestPackingItem {
|
|
const result = db.prepare(
|
|
'INSERT INTO packing_items (trip_id, name, category, checked) VALUES (?, ?, ?, 0)'
|
|
).run(tripId, overrides.name ?? 'Test Item', overrides.category ?? 'Clothing');
|
|
return db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid) as TestPackingItem;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reservations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TestReservation {
|
|
id: number;
|
|
trip_id: number;
|
|
title: string;
|
|
type: string;
|
|
}
|
|
|
|
export function createReservation(
|
|
db: Database.Database,
|
|
tripId: number,
|
|
overrides: Partial<{ title: string; type: string; day_id: number }> = {}
|
|
): TestReservation {
|
|
const result = db.prepare(
|
|
'INSERT INTO reservations (trip_id, title, type, day_id) VALUES (?, ?, ?, ?)'
|
|
).run(tripId, overrides.title ?? 'Test Reservation', overrides.type ?? 'flight', overrides.day_id ?? null);
|
|
return db.prepare('SELECT * FROM reservations WHERE id = ?').get(result.lastInsertRowid) as TestReservation;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Invite Tokens
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TestInviteToken {
|
|
id: number;
|
|
token: string;
|
|
max_uses: number | null;
|
|
used_count: number;
|
|
expires_at: string | null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Day Notes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TestDayNote {
|
|
id: number;
|
|
day_id: number;
|
|
trip_id: number;
|
|
text: string;
|
|
time: string | null;
|
|
icon: string;
|
|
}
|
|
|
|
export function createDayNote(
|
|
db: Database.Database,
|
|
dayId: number,
|
|
tripId: number,
|
|
overrides: Partial<{ text: string; time: string; icon: string }> = {}
|
|
): TestDayNote {
|
|
const result = db.prepare(
|
|
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, 9999)'
|
|
).run(dayId, tripId, overrides.text ?? 'Test note', overrides.time ?? null, overrides.icon ?? '📝');
|
|
return db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid) as TestDayNote;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Collab Notes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TestCollabNote {
|
|
id: number;
|
|
trip_id: number;
|
|
user_id: number;
|
|
title: string;
|
|
content: string | null;
|
|
category: string;
|
|
color: string;
|
|
pinned: number;
|
|
}
|
|
|
|
export function createCollabNote(
|
|
db: Database.Database,
|
|
tripId: number,
|
|
userId: number,
|
|
overrides: Partial<{ title: string; content: string; category: string; color: string }> = {}
|
|
): TestCollabNote {
|
|
const result = db.prepare(
|
|
'INSERT INTO collab_notes (trip_id, user_id, title, content, category, color) VALUES (?, ?, ?, ?, ?, ?)'
|
|
).run(
|
|
tripId,
|
|
userId,
|
|
overrides.title ?? 'Test Note',
|
|
overrides.content ?? null,
|
|
overrides.category ?? 'General',
|
|
overrides.color ?? '#6366f1'
|
|
);
|
|
return db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(result.lastInsertRowid) as TestCollabNote;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Day Assignments
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TestDayAssignment {
|
|
id: number;
|
|
day_id: number;
|
|
place_id: number;
|
|
order_index: number;
|
|
notes: string | null;
|
|
}
|
|
|
|
export function createDayAssignment(
|
|
db: Database.Database,
|
|
dayId: number,
|
|
placeId: number,
|
|
overrides: Partial<{ order_index: number; notes: string }> = {}
|
|
): TestDayAssignment {
|
|
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as { max: number | null };
|
|
const orderIndex = overrides.order_index ?? (maxOrder.max !== null ? maxOrder.max + 1 : 0);
|
|
const result = db.prepare(
|
|
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)'
|
|
).run(dayId, placeId, orderIndex, overrides.notes ?? null);
|
|
return db.prepare('SELECT * FROM day_assignments WHERE id = ?').get(result.lastInsertRowid) as TestDayAssignment;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bucket List
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TestBucketListItem {
|
|
id: number;
|
|
user_id: number;
|
|
name: string;
|
|
lat: number | null;
|
|
lng: number | null;
|
|
country_code: string | null;
|
|
notes: string | null;
|
|
}
|
|
|
|
export function createBucketListItem(
|
|
db: Database.Database,
|
|
userId: number,
|
|
overrides: Partial<{ name: string; lat: number; lng: number; country_code: string; notes: string }> = {}
|
|
): TestBucketListItem {
|
|
const result = db.prepare(
|
|
'INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes) VALUES (?, ?, ?, ?, ?, ?)'
|
|
).run(
|
|
userId,
|
|
overrides.name ?? 'Test Destination',
|
|
overrides.lat ?? null,
|
|
overrides.lng ?? null,
|
|
overrides.country_code ?? null,
|
|
overrides.notes ?? null
|
|
);
|
|
return db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid) as TestBucketListItem;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Visited Countries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function createVisitedCountry(
|
|
db: Database.Database,
|
|
userId: number,
|
|
countryCode: string
|
|
): void {
|
|
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, countryCode.toUpperCase());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Day Accommodations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TestDayAccommodation {
|
|
id: number;
|
|
trip_id: number;
|
|
place_id: number;
|
|
start_day_id: number;
|
|
end_day_id: number;
|
|
check_in: string | null;
|
|
check_out: string | null;
|
|
}
|
|
|
|
export function createDayAccommodation(
|
|
db: Database.Database,
|
|
tripId: number,
|
|
placeId: number,
|
|
startDayId: number,
|
|
endDayId: number,
|
|
overrides: Partial<{ check_in: string; check_out: string; confirmation: string }> = {}
|
|
): TestDayAccommodation {
|
|
const result = db.prepare(
|
|
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
).run(
|
|
tripId,
|
|
placeId,
|
|
startDayId,
|
|
endDayId,
|
|
overrides.check_in ?? null,
|
|
overrides.check_out ?? null,
|
|
overrides.confirmation ?? null
|
|
);
|
|
return db.prepare('SELECT * FROM day_accommodations WHERE id = ?').get(result.lastInsertRowid) as TestDayAccommodation;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MCP Tokens
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { createHash } from 'crypto';
|
|
|
|
export interface TestMcpToken {
|
|
id: number;
|
|
tokenHash: string;
|
|
rawToken: string;
|
|
}
|
|
|
|
export function createMcpToken(
|
|
db: Database.Database,
|
|
userId: number,
|
|
overrides: Partial<{ name: string; rawToken: string }> = {}
|
|
): TestMcpToken {
|
|
const rawToken = overrides.rawToken ?? `trek_test_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
|
|
const tokenPrefix = rawToken.slice(0, 12);
|
|
const result = db.prepare(
|
|
'INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, ?, ?, ?)'
|
|
).run(userId, tokenHash, tokenPrefix, overrides.name ?? 'Test Token');
|
|
return { id: result.lastInsertRowid as number, tokenHash, rawToken };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Invite Tokens
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function createInviteToken(
|
|
db: Database.Database,
|
|
overrides: Partial<{ token: string; max_uses: number; expires_at: string; created_by: number }> = {}
|
|
): TestInviteToken {
|
|
const token = overrides.token ?? `test-invite-${Date.now()}`;
|
|
// created_by is required by the schema; use an existing admin or create one
|
|
let createdBy = overrides.created_by;
|
|
if (!createdBy) {
|
|
const admin = db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").get() as { id: number } | undefined;
|
|
if (admin) {
|
|
createdBy = admin.id;
|
|
} else {
|
|
const any = db.prepare('SELECT id FROM users LIMIT 1').get() as { id: number } | undefined;
|
|
if (any) {
|
|
createdBy = any.id;
|
|
} else {
|
|
const r = db.prepare("INSERT INTO users (username, email, password_hash, role) VALUES ('invite_creator', 'invite_creator@test.example.com', 'x', 'admin')").run();
|
|
createdBy = r.lastInsertRowid as number;
|
|
}
|
|
}
|
|
}
|
|
const result = db.prepare(
|
|
'INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by) VALUES (?, ?, 0, ?, ?)'
|
|
).run(token, overrides.max_uses ?? 1, overrides.expires_at ?? null, createdBy);
|
|
return db.prepare('SELECT * FROM invite_tokens WHERE id = ?').get(result.lastInsertRowid) as TestInviteToken;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Notification helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Upsert a key/value pair into app_settings. */
|
|
export function setAppSetting(db: Database.Database, key: string, value: string): void {
|
|
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value);
|
|
}
|
|
|
|
/** Set the active notification channels (e.g. 'email', 'webhook', 'email,webhook', 'none'). */
|
|
export function setNotificationChannels(db: Database.Database, channels: string): void {
|
|
setAppSetting(db, 'notification_channels', channels);
|
|
}
|
|
|
|
/** Explicitly disable a per-user notification preference for a given event+channel combo. */
|
|
export function disableNotificationPref(
|
|
db: Database.Database,
|
|
userId: number,
|
|
eventType: string,
|
|
channel: string
|
|
): void {
|
|
db.prepare(
|
|
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 0)'
|
|
).run(userId, eventType, channel);
|
|
}
|