chore: apply prettier on the entire project

This commit is contained in:
jubnl
2026-05-25 21:59:42 +02:00
parent c130ed41be
commit 6bcdfbc34b
488 changed files with 82986 additions and 45830 deletions
+2 -6
View File
@@ -4,17 +4,13 @@
* Provides utilities to generate JWTs and authenticate supertest requests
* using the fixed test JWT_SECRET from TEST_CONFIG.
*/
import { TEST_CONFIG } from './test-db';
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' }
);
return jwt.sign({ id: userId, ...extraClaims }, TEST_CONFIG.JWT_SECRET, { algorithm: 'HS256', expiresIn: '1h' });
}
/**
+227 -183
View File
@@ -3,11 +3,16 @@
* 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';
import { encrypt_api_key } from '../../src/services/apiKeyCrypto';
import { encryptMfaSecret } from '../../src/services/mfaCrypto';
import bcrypt from 'bcryptjs';
import Database from 'better-sqlite3';
// ---------------------------------------------------------------------------
// MCP Tokens
// ---------------------------------------------------------------------------
import { createHash } from 'crypto';
let _userSeq = 0;
let _tripSeq = 0;
@@ -28,7 +33,7 @@ export interface TestUser {
export function createUser(
db: Database.Database,
overrides: Partial<{ username: string; email: string; password: string; role: 'admin' | 'user' }> = {}
overrides: Partial<{ username: string; email: string; password: string; role: 'admin' | 'user' }> = {},
): { user: TestUser; password: string } {
_userSeq++;
const password = overrides.password ?? `TestPass${_userSeq}!`;
@@ -37,9 +42,9 @@ export function createUser(
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 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 };
@@ -47,7 +52,7 @@ export function createUser(
export function createAdmin(
db: Database.Database,
overrides: Partial<{ username: string; email: string; password: string }> = {}
overrides: Partial<{ username: string; email: string; password: string }> = {},
): { user: TestUser; password: string } {
return createUser(db, { ...overrides, role: 'admin' });
}
@@ -59,13 +64,11 @@ export function createAdmin(
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' }> = {}
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);
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 };
}
@@ -85,13 +88,13 @@ export interface TestTrip {
export function createTrip(
db: Database.Database,
userId: number,
overrides: Partial<{ title: string; start_date: string; end_date: string; description: string }> = {}
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);
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) {
@@ -123,14 +126,16 @@ export interface TestDay {
export function createDay(
db: Database.Database,
tripId: number,
overrides: Partial<{ date: string; title: string; day_number: 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 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);
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;
}
@@ -150,22 +155,22 @@ export interface TestPlace {
export function createPlace(
db: Database.Database,
tripId: number,
overrides: Partial<{ name: string; lat: number; lng: number; category_id: number; description: string }> = {}
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
);
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;
}
@@ -192,16 +197,11 @@ export interface TestBudgetItem {
export function createBudgetItem(
db: Database.Database,
tripId: number,
overrides: Partial<{ name: string; category: string; total_price: 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
);
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;
}
@@ -220,11 +220,11 @@ export interface TestPackingItem {
export function createPackingItem(
db: Database.Database,
tripId: number,
overrides: Partial<{ name: string; category: string }> = {}
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');
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;
}
@@ -242,11 +242,11 @@ export interface TestReservation {
export function createReservation(
db: Database.Database,
tripId: number,
overrides: Partial<{ title: string; type: string; day_id: 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);
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;
}
@@ -279,11 +279,11 @@ export function createDayNote(
db: Database.Database,
dayId: number,
tripId: number,
overrides: Partial<{ text: string; time: string; icon: string }> = {}
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 ?? '📝');
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;
}
@@ -306,18 +306,18 @@ export function createCollabNote(
db: Database.Database,
tripId: number,
userId: number,
overrides: Partial<{ title: string; content: string; category: string; color: string }> = {}
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'
);
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;
}
@@ -337,13 +337,15 @@ export interface TestTodoItem {
export function createTodoItem(
db: Database.Database,
tripId: number,
overrides: Partial<{ name: string; category: string; checked: number }> = {}
overrides: Partial<{ name: string; category: string; checked: number }> = {},
): TestTodoItem {
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM todo_items WHERE trip_id = ?').get(tripId) as { max: number | null };
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM todo_items WHERE trip_id = ?').get(tripId) as {
max: number | null;
};
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)'
).run(tripId, overrides.name ?? 'Test Todo', overrides.checked ?? 0, overrides.category ?? null, sortOrder);
const result = db
.prepare('INSERT INTO todo_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)')
.run(tripId, overrides.name ?? 'Test Todo', overrides.checked ?? 0, overrides.category ?? null, sortOrder);
return db.prepare('SELECT * FROM todo_items WHERE id = ?').get(result.lastInsertRowid) as TestTodoItem;
}
@@ -363,13 +365,15 @@ export function createDayAssignment(
db: Database.Database,
dayId: number,
placeId: number,
overrides: Partial<{ order_index: number; notes: string }> = {}
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 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);
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;
}
@@ -390,18 +394,18 @@ export interface TestBucketListItem {
export function createBucketListItem(
db: Database.Database,
userId: number,
overrides: Partial<{ name: string; lat: number; lng: number; country_code: string; notes: string }> = {}
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
);
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;
}
@@ -409,12 +413,11 @@ export function createBucketListItem(
// 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());
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(),
);
}
// ---------------------------------------------------------------------------
@@ -437,28 +440,26 @@ export function createDayAccommodation(
placeId: number,
startDayId: number,
endDayId: number,
overrides: Partial<{ check_in: string; check_out: string; confirmation: string }> = {}
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;
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;
@@ -468,14 +469,14 @@ export interface TestMcpToken {
export function createMcpToken(
db: Database.Database,
userId: number,
overrides: Partial<{ name: string; rawToken: string }> = {}
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');
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 };
}
@@ -485,7 +486,7 @@ export function createMcpToken(
export function createInviteToken(
db: Database.Database,
overrides: Partial<{ token: string; max_uses: number; expires_at: string; created_by: number }> = {}
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
@@ -499,14 +500,18 @@ export function createInviteToken(
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();
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);
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;
}
@@ -529,10 +534,10 @@ export function disableNotificationPref(
db: Database.Database,
userId: number,
eventType: string,
channel: string
channel: string,
): void {
db.prepare(
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 0)'
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 0)',
).run(userId, eventType, channel);
}
@@ -556,25 +561,33 @@ export function addTripPhoto(
userId: number,
assetId: string,
provider: string,
opts: { shared?: boolean; albumLinkId?: number } = {}
opts: { shared?: boolean; albumLinkId?: number } = {},
): TestTripPhoto {
// Insert into trek_photos first (central registry)
db.prepare(
'INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
).run(provider, assetId, userId);
const trekPhoto = db.prepare(
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
).get(provider, assetId, userId) as { id: number };
db.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run(
provider,
assetId,
userId,
);
const trekPhoto = db
.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
.get(provider, assetId, userId) as { id: number };
const result = db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, trekPhoto.id, opts.shared ? 1 : 0, opts.albumLinkId ?? null);
return db.prepare(`
const result = db
.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)',
)
.run(tripId, userId, trekPhoto.id, opts.shared ? 1 : 0, opts.albumLinkId ?? null);
return db
.prepare(
`
SELECT tp.id, tp.trip_id, tp.user_id, tkp.asset_id, tkp.provider, tp.shared, tp.album_link_id
FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.id = ?
`).get(result.lastInsertRowid) as TestTripPhoto;
`,
)
.get(result.lastInsertRowid) as TestTripPhoto;
}
export interface TestAlbumLink {
@@ -592,22 +605,20 @@ export function addAlbumLink(
userId: number,
provider: string,
albumId: string,
albumName = 'Test Album'
albumName = 'Test Album',
): TestAlbumLink {
const result = db.prepare(
'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, provider, albumId, albumName);
const result = db
.prepare('INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)')
.run(tripId, userId, provider, albumId, albumName);
return db.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(result.lastInsertRowid) as TestAlbumLink;
}
export function setImmichCredentials(
db: Database.Database,
userId: number,
url: string,
apiKey: string
): void {
db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?')
.run(url, encrypt_api_key(apiKey), userId);
export function setImmichCredentials(db: Database.Database, userId: number, url: string, apiKey: string): void {
db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
url,
encrypt_api_key(apiKey),
userId,
);
}
export function setSynologyCredentials(
@@ -615,10 +626,14 @@ export function setSynologyCredentials(
userId: number,
url: string,
username: string,
password: string
password: string,
): void {
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?')
.run(url, username, encrypt_api_key(password), userId);
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run(
url,
username,
encrypt_api_key(password),
userId,
);
}
// ---------------------------------------------------------------------------
@@ -627,29 +642,38 @@ export function setSynologyCredentials(
export function createCategory(
db: Database.Database,
overrides: { name?: string; color?: string; icon?: string; user_id?: number | null } = {}
overrides: { name?: string; color?: string; icon?: string; user_id?: number | null } = {},
) {
const name = overrides.name ?? `Test Category ${++_categorySeq}`;
const color = overrides.color ?? '#6366f1';
const icon = overrides.icon ?? '📍';
const userId = overrides.user_id ?? null;
const result = db.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)').run(name, color, icon, userId);
return db.prepare('SELECT * FROM categories WHERE id = ?').get(result.lastInsertRowid) as { id: number; name: string; color: string; icon: string; user_id: number | null };
const result = db
.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)')
.run(name, color, icon, userId);
return db.prepare('SELECT * FROM categories WHERE id = ?').get(result.lastInsertRowid) as {
id: number;
name: string;
color: string;
icon: string;
user_id: number | null;
};
}
// ---------------------------------------------------------------------------
// Tags
// ---------------------------------------------------------------------------
export function createTag(
db: Database.Database,
userId: number,
overrides: { name?: string; color?: string } = {}
) {
export function createTag(db: Database.Database, userId: number, overrides: { name?: string; color?: string } = {}) {
const name = overrides.name ?? `Test Tag ${++_tagSeq}`;
const color = overrides.color ?? '#10b981';
const result = db.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(userId, name, color);
return db.prepare('SELECT * FROM tags WHERE id = ?').get(result.lastInsertRowid) as { id: number; user_id: number; name: string; color: string };
return db.prepare('SELECT * FROM tags WHERE id = ?').get(result.lastInsertRowid) as {
id: number;
user_id: number;
name: string;
color: string;
};
}
// ---------------------------------------------------------------------------
@@ -672,21 +696,25 @@ export interface TestJourney {
export function createJourney(
db: Database.Database,
userId: number,
overrides: Partial<{ title: string; subtitle: string; status: string }> = {}
overrides: Partial<{ title: string; subtitle: string; status: string }> = {},
): TestJourney {
_journeySeq++;
const title = overrides.title ?? `Test Journey ${_journeySeq}`;
const now = Date.now();
const result = db.prepare(
'INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(userId, title, overrides.subtitle ?? null, overrides.status ?? 'active', now, now);
const result = db
.prepare(
'INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
)
.run(userId, title, overrides.subtitle ?? null, overrides.status ?? 'active', now, now);
const journeyId = result.lastInsertRowid as number;
// Auto-add owner as contributor
db.prepare(
"INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'owner', ?)"
).run(journeyId, userId, now);
db.prepare("INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'owner', ?)").run(
journeyId,
userId,
now,
);
return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as TestJourney;
}
@@ -705,23 +733,37 @@ export function createJourneyEntry(
db: Database.Database,
journeyId: number,
authorId: number,
overrides: Partial<{ type: string; entry_date: string; title: string; story: string; location_name: string; mood: string; weather: string }> = {}
overrides: Partial<{
type: string;
entry_date: string;
title: string;
story: string;
location_name: string;
mood: string;
weather: string;
}> = {},
): TestJourneyEntry {
const now = Date.now();
const result = db.prepare(`
const result = db
.prepare(
`
INSERT INTO journey_entries (journey_id, author_id, type, entry_date, title, story, location_name, mood, weather, visibility, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'private', 0, ?, ?)
`).run(
journeyId, authorId,
overrides.type ?? 'entry',
overrides.entry_date ?? '2026-01-15',
overrides.title ?? null,
overrides.story ?? null,
overrides.location_name ?? null,
overrides.mood ?? null,
overrides.weather ?? null,
now, now
);
`,
)
.run(
journeyId,
authorId,
overrides.type ?? 'entry',
overrides.entry_date ?? '2026-01-15',
overrides.title ?? null,
overrides.story ?? null,
overrides.location_name ?? null,
overrides.mood ?? null,
overrides.weather ?? null,
now,
now,
);
return db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(result.lastInsertRowid) as TestJourneyEntry;
}
@@ -729,15 +771,17 @@ export function addJourneyContributor(
db: Database.Database,
journeyId: number,
userId: number,
role: 'editor' | 'viewer' = 'editor'
role: 'editor' | 'viewer' = 'editor',
): void {
db.prepare(
'INSERT OR IGNORE INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)'
'INSERT OR IGNORE INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)',
).run(journeyId, userId, role, Date.now());
}
export function linkTripToJourney(db: Database.Database, journeyId: number, tripId: number): void {
db.prepare(
'INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, linked_at) VALUES (?, ?, ?)'
).run(journeyId, tripId, Date.now());
db.prepare('INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, linked_at) VALUES (?, ?, ?)').run(
journeyId,
tripId,
Date.now(),
);
}
+16 -7
View File
@@ -9,12 +9,11 @@
* const result = await harness.client.callTool({ name: 'create_trip', arguments: { title: 'Test' } });
* await harness.cleanup();
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { Client } from '@modelcontextprotocol/sdk/client/index';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
import { registerResources } from '../../src/mcp/resources';
import { registerTools } from '../../src/mcp/tools';
import { Client } from '@modelcontextprotocol/sdk/client/index';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
export interface McpHarness {
client: Client;
@@ -50,8 +49,16 @@ export async function createMcpHarness(options: McpHarnessOptions): Promise<McpH
await client.connect(clientTransport);
const cleanup = async () => {
try { await client.close(); } catch { /* ignore */ }
try { await server.close(); } catch { /* ignore */ }
try {
await client.close();
} catch {
/* ignore */
}
try {
await server.close();
} catch {
/* ignore */
}
};
return { client, server, cleanup };
@@ -59,7 +66,9 @@ export async function createMcpHarness(options: McpHarnessOptions): Promise<McpH
/** Parse JSON from a callTool result (first text content item). */
export function parseToolResult(result: Awaited<ReturnType<Client['callTool']>>): unknown {
const text = result.content.find((c: { type: string }) => c.type === 'text') as { type: 'text'; text: string } | undefined;
const text = result.content.find((c: { type: string }) => c.type === 'text') as
| { type: 'text'; text: string }
| undefined;
if (!text) throw new Error('No text content in tool result');
return JSON.parse(text.text);
}
+112 -28
View File
@@ -15,10 +15,10 @@
* beforeEach(() => resetTestDb(testDb));
* afterAll(() => testDb.close());
*/
import { runMigrations } from '../../src/db/migrations';
import { createTables } from '../../src/db/schema';
import Database from 'better-sqlite3';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
// Tables to clear on reset, child-before-parent to be safe (FK checks are OFF during reset).
// Keep in sync with schema.ts + migrations.ts. Intentionally excluded: categories, addons,
@@ -116,32 +116,102 @@ const DEFAULT_CATEGORIES = [
];
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: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 0, sort_order: 13 },
{ id: 'collab', name: 'Collab', description: 'Notes, polls, live chat', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
{
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: 'naver_list_import',
name: 'Naver List Import',
description: 'Import places from shared Naver Maps lists',
type: 'trip',
icon: 'Link2',
enabled: 0,
sort_order: 13,
},
{
id: 'collab',
name: 'Collab',
description: 'Notes, polls, live chat',
type: 'trip',
icon: 'Users',
enabled: 1,
sort_order: 6,
},
];
const DEFAULT_PHOTO_PROVIDERS = [
{ id: 'immich', name: 'Immich', enabled: 1 },
{ id: 'synologyphotos', name: 'Synology Photos', enabled: 1 },
{ id: 'immich', name: 'Immich', enabled: 1 },
{ id: 'synologyphotos', name: 'Synology Photos', enabled: 1 },
];
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 (?, ?, ?, ?, ?, ?, ?)');
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);
try {
const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)');
const insertProvider = db.prepare(
'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
);
for (const p of DEFAULT_PHOTO_PROVIDERS) insertProvider.run(p.id, p.name, p.id, 'Image', p.enabled, 0);
} catch { /* table may not exist in very old schemas */ }
} catch {
/* table may not exist in very old schemas */
}
}
/**
@@ -166,7 +236,7 @@ export function createTestDb(): Database.Database {
export function resetTestDb(db: Database.Database): void {
db.exec('PRAGMA foreign_keys = OFF');
const existingTables = new Set(
(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[]).map(r => r.name)
(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[]).map((r) => r.name),
);
for (const table of RESET_TABLES) {
if (existingTables.has(table)) {
@@ -199,38 +269,52 @@ export function buildDbMock(testDb: Database.Database) {
category_icon: string | null;
[key: string]: unknown;
}
const place = testDb.prepare(`
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;
`,
)
.get(placeId) as PlaceRow | undefined;
if (!place) return null;
const tags = testDb.prepare(`
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);
`,
)
.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,
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(`
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);
`,
)
.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);
+22 -9
View File
@@ -26,7 +26,6 @@
* expect(msg.type).toBe('welcome');
* });
*/
import WebSocket from 'ws';
export interface WsMessage {
@@ -44,35 +43,40 @@ export class WsTestClient {
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 === '*');
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 */ }
} 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);
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);
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); },
resolve: (msg) => {
clearTimeout(timer);
resolve(msg);
},
reject,
});
});
@@ -93,8 +97,14 @@ export class WsTestClient {
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); });
this.ws.once('open', () => {
clearTimeout(timer);
resolve();
});
this.ws.once('error', (err) => {
clearTimeout(timer);
reject(err);
});
});
}
@@ -103,7 +113,10 @@ export class WsTestClient {
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); });
this.ws.once('close', (code) => {
clearTimeout(timer);
resolve(code);
});
});
}
}