* add test suite, mostly covers integration testing, tests are only backend side * workflow runs the correct script * workflow runs the correct script * workflow runs the correct script * unit tests incoming * Fix multer silent rejections and error handler info leak - Revert cb(null, false) to cb(new Error(...)) in auth.ts, collab.ts, and files.ts so invalid uploads return an error instead of silently dropping the file - Error handler in app.ts now always returns 500 / "Internal server error" instead of forwarding err.message to the client * Use statusCode consistently for multer errors and error handler - Error handler in app.ts reads err.statusCode to forward the correct HTTP status while keeping the response body generic
288 lines
10 KiB
TypeScript
288 lines
10 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;
|
|
}
|
|
|
|
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;
|
|
}
|