Add comprehensive backend test suite (#339)
* 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
This commit is contained in:
34
server/tests/helpers/auth.ts
Normal file
34
server/tests/helpers/auth.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Auth helpers for integration tests.
|
||||
*
|
||||
* Provides utilities to generate JWTs and authenticate supertest requests
|
||||
* using the fixed test JWT_SECRET from TEST_CONFIG.
|
||||
*/
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { TEST_CONFIG } from './test-db';
|
||||
|
||||
/** Signs a JWT for the given user ID using the test secret. */
|
||||
export function generateToken(userId: number, extraClaims: Record<string, unknown> = {}): string {
|
||||
return jwt.sign(
|
||||
{ id: userId, ...extraClaims },
|
||||
TEST_CONFIG.JWT_SECRET,
|
||||
{ algorithm: 'HS256', expiresIn: '1h' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cookie string suitable for supertest:
|
||||
* request(app).get('/api/...').set('Cookie', authCookie(userId))
|
||||
*/
|
||||
export function authCookie(userId: number): string {
|
||||
return `trek_session=${generateToken(userId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an Authorization header object suitable for supertest:
|
||||
* request(app).get('/api/...').set(authHeader(userId))
|
||||
*/
|
||||
export function authHeader(userId: number): Record<string, string> {
|
||||
return { Authorization: `Bearer ${generateToken(userId)}` };
|
||||
}
|
||||
287
server/tests/helpers/factories.ts
Normal file
287
server/tests/helpers/factories.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
193
server/tests/helpers/test-db.ts
Normal file
193
server/tests/helpers/test-db.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* In-memory SQLite test database helper.
|
||||
*
|
||||
* Usage in an integration test file:
|
||||
*
|
||||
* import { createTestDb, resetTestDb } from '../helpers/test-db';
|
||||
* import { buildDbMock } from '../helpers/test-db';
|
||||
*
|
||||
* // Declare at module scope (before vi.mock so it's available in factory)
|
||||
* const testDb = createTestDb();
|
||||
*
|
||||
* vi.mock('../../src/db/database', () => buildDbMock(testDb));
|
||||
* vi.mock('../../src/config', () => TEST_CONFIG);
|
||||
*
|
||||
* beforeEach(() => resetTestDb(testDb));
|
||||
* afterAll(() => testDb.close());
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
|
||||
// Tables to clear on reset, ordered to avoid FK violations
|
||||
const RESET_TABLES = [
|
||||
'file_links',
|
||||
'collab_poll_votes',
|
||||
'collab_messages',
|
||||
'collab_poll_options',
|
||||
'collab_polls',
|
||||
'collab_notes',
|
||||
'day_notes',
|
||||
'assignment_participants',
|
||||
'day_assignments',
|
||||
'packing_category_assignees',
|
||||
'packing_bags',
|
||||
'packing_items',
|
||||
'budget_item_members',
|
||||
'budget_items',
|
||||
'trip_files',
|
||||
'share_tokens',
|
||||
'photos',
|
||||
'reservations',
|
||||
'day_accommodations',
|
||||
'place_tags',
|
||||
'places',
|
||||
'days',
|
||||
'trip_members',
|
||||
'trips',
|
||||
'vacay_entries',
|
||||
'vacay_company_holidays',
|
||||
'vacay_holiday_calendars',
|
||||
'vacay_plan_members',
|
||||
'vacay_years',
|
||||
'vacay_plans',
|
||||
'atlas_visited_countries',
|
||||
'atlas_bucket_list',
|
||||
'notifications',
|
||||
'audit_log',
|
||||
'user_settings',
|
||||
'mcp_tokens',
|
||||
'mcp_sessions',
|
||||
'invite_tokens',
|
||||
'tags',
|
||||
'app_settings',
|
||||
'users',
|
||||
];
|
||||
|
||||
const DEFAULT_CATEGORIES = [
|
||||
{ name: 'Hotel', color: '#3b82f6', icon: '🏨' },
|
||||
{ name: 'Restaurant', color: '#ef4444', icon: '🍽️' },
|
||||
{ name: 'Attraction', color: '#8b5cf6', icon: '🏛️' },
|
||||
{ name: 'Shopping', color: '#f59e0b', icon: '🛍️' },
|
||||
{ name: 'Transport', color: '#6b7280', icon: '🚌' },
|
||||
{ name: 'Activity', color: '#10b981', icon: '🎯' },
|
||||
{ name: 'Bar/Cafe', color: '#f97316', icon: '☕' },
|
||||
{ name: 'Beach', color: '#06b6d4', icon: '🏖️' },
|
||||
{ name: 'Nature', color: '#84cc16', icon: '🌿' },
|
||||
{ name: 'Other', color: '#6366f1', icon: '📍' },
|
||||
];
|
||||
|
||||
const DEFAULT_ADDONS = [
|
||||
{ id: 'packing', name: 'Packing List', description: 'Pack your bags', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
|
||||
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
|
||||
{ id: 'documents', name: 'Documents', description: 'Manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
|
||||
{ id: 'vacay', name: 'Vacay', description: 'Vacation day planner', type: 'global', icon: 'CalendarDays',enabled: 1, sort_order: 10 },
|
||||
{ id: 'atlas', name: 'Atlas', description: 'Visited countries map', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
||||
{ id: 'mcp', name: 'MCP', description: 'AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
|
||||
{ id: 'collab', name: 'Collab', description: 'Notes, polls, live chat', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
||||
];
|
||||
|
||||
function seedDefaults(db: Database.Database): void {
|
||||
const insertCat = db.prepare('INSERT OR IGNORE INTO categories (name, color, icon) VALUES (?, ?, ?)');
|
||||
for (const cat of DEFAULT_CATEGORIES) insertCat.run(cat.name, cat.color, cat.icon);
|
||||
|
||||
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
for (const a of DEFAULT_ADDONS) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fresh in-memory SQLite database with the full schema and migrations applied.
|
||||
* Default categories and addons are seeded. No users are created.
|
||||
*/
|
||||
export function createTestDb(): Database.Database {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
createTables(db);
|
||||
runMigrations(db);
|
||||
seedDefaults(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all user-generated data from the test DB and re-seeds defaults.
|
||||
* Call in beforeEach() for test isolation within a file.
|
||||
*/
|
||||
export function resetTestDb(db: Database.Database): void {
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
for (const table of RESET_TABLES) {
|
||||
try { db.exec(`DELETE FROM "${table}"`); } catch { /* table may not exist in older schemas */ }
|
||||
}
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
seedDefaults(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the mock factory for vi.mock('../../src/db/database', ...).
|
||||
* The returned object mirrors the shape of database.ts exports.
|
||||
*
|
||||
* @example
|
||||
* const testDb = createTestDb();
|
||||
* vi.mock('../../src/db/database', () => buildDbMock(testDb));
|
||||
*/
|
||||
export function buildDbMock(testDb: Database.Database) {
|
||||
return {
|
||||
db: testDb,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number | string) => {
|
||||
interface PlaceRow {
|
||||
id: number;
|
||||
category_id: number | null;
|
||||
category_name: string | null;
|
||||
category_color: string | null;
|
||||
category_icon: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
const place = testDb.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.id = ?
|
||||
`).get(placeId) as PlaceRow | undefined;
|
||||
|
||||
if (!place) return null;
|
||||
|
||||
const tags = testDb.prepare(`
|
||||
SELECT t.* FROM tags t
|
||||
JOIN place_tags pt ON t.id = pt.tag_id
|
||||
WHERE pt.place_id = ?
|
||||
`).all(placeId);
|
||||
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id ? {
|
||||
id: place.category_id,
|
||||
name: place.category_name,
|
||||
color: place.category_color,
|
||||
icon: place.category_icon,
|
||||
} : null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: number | string, userId: number) => {
|
||||
return testDb.prepare(`
|
||||
SELECT t.id, t.user_id FROM trips t
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||||
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
||||
`).get(userId, tripId, userId);
|
||||
},
|
||||
isOwner: (tripId: number | string, userId: number) => {
|
||||
return !!testDb.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Fixed config mock — use with vi.mock('../../src/config', () => TEST_CONFIG) */
|
||||
export const TEST_CONFIG = {
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
};
|
||||
109
server/tests/helpers/ws-client.ts
Normal file
109
server/tests/helpers/ws-client.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* WebSocket test client helper.
|
||||
*
|
||||
* Usage:
|
||||
* import http from 'http';
|
||||
* import { setupWebSocket } from '../../src/websocket';
|
||||
* import { WsTestClient, getWsToken } from '../helpers/ws-client';
|
||||
*
|
||||
* let server: http.Server;
|
||||
* let client: WsTestClient;
|
||||
*
|
||||
* beforeAll(async () => {
|
||||
* const app = createApp();
|
||||
* server = http.createServer(app);
|
||||
* setupWebSocket(server);
|
||||
* await new Promise<void>(res => server.listen(0, res));
|
||||
* });
|
||||
*
|
||||
* afterAll(() => server.close());
|
||||
*
|
||||
* it('connects', async () => {
|
||||
* const addr = server.address() as AddressInfo;
|
||||
* const token = await getWsToken(addr.port, userId);
|
||||
* client = new WsTestClient(`ws://localhost:${addr.port}/ws?token=${token}`);
|
||||
* const msg = await client.waitForMessage('welcome');
|
||||
* expect(msg.type).toBe('welcome');
|
||||
* });
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
export interface WsMessage {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class WsTestClient {
|
||||
private ws: WebSocket;
|
||||
private messageQueue: WsMessage[] = [];
|
||||
private waiters: Array<{ type: string; resolve: (msg: WsMessage) => void; reject: (err: Error) => void }> = [];
|
||||
|
||||
constructor(url: string) {
|
||||
this.ws = new WebSocket(url);
|
||||
this.ws.on('message', (data: WebSocket.RawData) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString()) as WsMessage;
|
||||
const waiterIdx = this.waiters.findIndex(w => w.type === msg.type || w.type === '*');
|
||||
if (waiterIdx >= 0) {
|
||||
const waiter = this.waiters.splice(waiterIdx, 1)[0];
|
||||
waiter.resolve(msg);
|
||||
} else {
|
||||
this.messageQueue.push(msg);
|
||||
}
|
||||
} catch { /* ignore malformed messages */ }
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait for a message of the given type (or '*' for any). */
|
||||
waitForMessage(type: string, timeoutMs = 5000): Promise<WsMessage> {
|
||||
// Check if already in queue
|
||||
const idx = this.messageQueue.findIndex(m => type === '*' || m.type === type);
|
||||
if (idx >= 0) {
|
||||
return Promise.resolve(this.messageQueue.splice(idx, 1)[0]);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const waiterIdx = this.waiters.findIndex(w => w.resolve === resolve);
|
||||
if (waiterIdx >= 0) this.waiters.splice(waiterIdx, 1);
|
||||
reject(new Error(`Timed out waiting for WS message type="${type}" after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
this.waiters.push({
|
||||
type,
|
||||
resolve: (msg) => { clearTimeout(timer); resolve(msg); },
|
||||
reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Send a JSON message. */
|
||||
send(msg: Record<string, unknown>): void {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
/** Close the connection. */
|
||||
close(): void {
|
||||
this.ws.close();
|
||||
}
|
||||
|
||||
/** Wait for the connection to be open. */
|
||||
waitForOpen(timeoutMs = 3000): Promise<void> {
|
||||
if (this.ws.readyState === WebSocket.OPEN) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('WS open timed out')), timeoutMs);
|
||||
this.ws.once('open', () => { clearTimeout(timer); resolve(); });
|
||||
this.ws.once('error', (err) => { clearTimeout(timer); reject(err); });
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait for the connection to close. */
|
||||
waitForClose(timeoutMs = 3000): Promise<number> {
|
||||
if (this.ws.readyState === WebSocket.CLOSED) return Promise.resolve(1000);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('WS close timed out')), timeoutMs);
|
||||
this.ws.once('close', (code) => { clearTimeout(timer); resolve(code); });
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user