Files
TREK/server/tests/helpers/factories.ts
jubnl 1bddb3c588 refactor(mcp): replace direct DB access with service layer calls
Replace all db.prepare() calls in mcp/index.ts, mcp/resources.ts, and
mcp/tools.ts with calls to the service layer. Add missing service functions:
- authService: isDemoUser, verifyMcpToken, verifyJwtToken
- adminService: isAddonEnabled
- atlasService: listVisitedCountries
- tripService: getTripSummary, listTrips with null archived param

Also fix getAssignmentWithPlace and formatAssignmentWithPlace to expose
place_id, assignment_time, and assignment_end_time at the top level, and
fix updateDay to correctly handle null title for clearing.

Add comprehensive unit and integration test suite for the MCP layer (821 tests all passing).
2026-04-04 18:12:53 +02:00

483 lines
17 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;
}