Merge remote-tracking branch 'origin/dev' into naver-list-import

This commit is contained in:
Marco Sadowski
2026-04-10 15:35:16 +02:00
291 changed files with 62537 additions and 1952 deletions
+59
View File
@@ -11,6 +11,8 @@ import { encrypt_api_key } from '../../src/services/apiKeyCrypto';
let _userSeq = 0;
let _tripSeq = 0;
let _categorySeq = 0;
let _tagSeq = 0;
// ---------------------------------------------------------------------------
// Users
@@ -319,6 +321,32 @@ export function createCollabNote(
return db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(result.lastInsertRowid) as TestCollabNote;
}
// ---------------------------------------------------------------------------
// Todo Items
// ---------------------------------------------------------------------------
export interface TestTodoItem {
id: number;
trip_id: number;
name: string;
checked: number;
category: string | null;
sort_order: number;
}
export function createTodoItem(
db: Database.Database,
tripId: 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 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);
return db.prepare('SELECT * FROM todo_items WHERE id = ?').get(result.lastInsertRowid) as TestTodoItem;
}
// ---------------------------------------------------------------------------
// Day Assignments
// ---------------------------------------------------------------------------
@@ -579,3 +607,34 @@ export function setSynologyCredentials(
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?')
.run(url, username, encrypt_api_key(password), userId);
}
// ---------------------------------------------------------------------------
// Categories
// ---------------------------------------------------------------------------
export function createCategory(
db: Database.Database,
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 };
}
// ---------------------------------------------------------------------------
// Tags
// ---------------------------------------------------------------------------
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 };
}
+40 -10
View File
@@ -20,48 +20,73 @@ 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
// 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,
// photo_providers, photo_provider_fields, schema_version (seed/config data, not user data).
const RESET_TABLES = [
// Collab
'file_links',
'collab_message_reactions',
'collab_poll_votes',
'collab_messages',
'collab_poll_options',
'collab_polls',
'collab_notes',
// Day content
'day_notes',
'todo_category_assignees',
'todo_items',
'assignment_participants',
'day_assignments',
// Places
'place_regions',
'place_tags',
'places',
// Packing
'packing_category_assignees',
'packing_bag_members',
'packing_bags',
'packing_template_items',
'packing_template_categories',
'packing_templates',
'packing_items',
// Budget
'budget_item_members',
'budget_items',
// Photos & files
'trip_photos',
'trip_album_links',
'trip_files',
'share_tokens',
'photos',
// Reservations
'reservation_day_positions',
'reservations',
// Accommodations & days
'day_accommodations',
'place_tags',
'places',
'days',
// Trip
'share_tokens',
'trip_members',
'trips',
// Vacay
'vacay_entries',
'vacay_company_holidays',
'vacay_holiday_calendars',
'vacay_plan_members',
'vacay_user_colors',
'vacay_user_years',
'vacay_years',
'vacay_plans',
'atlas_visited_countries',
'atlas_bucket_list',
// Atlas
'visited_regions',
'visited_countries',
'bucket_list',
// Notifications & audit
'notification_channel_preferences',
'notifications',
'audit_log',
'user_settings',
// User data
'settings',
'mcp_tokens',
'mcp_sessions',
'invite_tokens',
'tags',
'app_settings',
@@ -130,8 +155,13 @@ 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)
);
for (const table of RESET_TABLES) {
try { db.exec(`DELETE FROM "${table}"`); } catch { /* table may not exist in older schemas */ }
if (existingTables.has(table)) {
db.exec(`DELETE FROM "${table}"`);
}
}
db.exec('PRAGMA foreign_keys = ON');
seedDefaults(db);
+186 -8
View File
@@ -96,7 +96,7 @@ describe('Admin user management', () => {
.get('/api/admin/users')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.users.length).toBeGreaterThanOrEqual(3);
expect(res.body.users).toHaveLength(3);
});
it('ADMIN-002 — POST /admin/users creates a user', async () => {
@@ -142,6 +142,10 @@ describe('Admin user management', () => {
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Verify the row is actually gone from the DB
const deleted = testDb.prepare('SELECT id FROM users WHERE id = ?').get(user.id);
expect(deleted).toBeUndefined();
});
it('ADMIN-006 — admin cannot delete their own account', async () => {
@@ -187,19 +191,25 @@ describe('Permissions management', () => {
expect(Array.isArray(res.body.permissions)).toBe(true);
});
it('ADMIN-008 — PUT /admin/permissions updates permissions', async () => {
it('ADMIN-008 — PUT /admin/permissions updates permissions and change persists', async () => {
const { user: admin } = createAdmin(testDb);
const getRes = await request(app)
.get('/api/admin/permissions')
.set('Cookie', authCookie(admin.id));
const currentPerms = getRes.body;
// Change trip_create from its default ('everybody') to 'admin'
const res = await request(app)
.put('/api/admin/permissions')
.set('Cookie', authCookie(admin.id))
.send({ permissions: currentPerms });
.send({ permissions: { trip_create: 'admin' } });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Re-fetch and verify the change persisted
const getRes = await request(app)
.get('/api/admin/permissions')
.set('Cookie', authCookie(admin.id));
expect(getRes.status).toBe(200);
const tripCreatePerm = getRes.body.permissions.find((p: any) => p.key === 'trip_create');
expect(tripCreatePerm).toBeDefined();
expect(tripCreatePerm.level).toBe('admin');
});
it('ADMIN-008 — PUT /admin/permissions without object returns 400', async () => {
@@ -351,3 +361,171 @@ describe('JWT rotation', () => {
expect(res.body.success).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Packing template CRUD (full)
// ─────────────────────────────────────────────────────────────────────────────
describe('Packing template CRUD (full)', () => {
async function makeTemplate(admin: any) {
const res = await request(app)
.post('/api/admin/packing-templates')
.set('Cookie', authCookie(admin.id))
.send({ name: 'Test Template' });
return res.body.template;
}
it('ADMIN-019 — GET /admin/packing-templates/:id returns template', async () => {
const { user: admin } = createAdmin(testDb);
const template = await makeTemplate(admin);
const res = await request(app)
.get(`/api/admin/packing-templates/${template.id}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.template.id).toBe(template.id);
expect(res.body.template.name).toBe('Test Template');
});
it('ADMIN-019b — GET /admin/packing-templates/:id returns 404 for missing', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/admin/packing-templates/99999')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(404);
});
it('ADMIN-020 — PUT /admin/packing-templates/:id updates name', async () => {
const { user: admin } = createAdmin(testDb);
const template = await makeTemplate(admin);
const res = await request(app)
.put(`/api/admin/packing-templates/${template.id}`)
.set('Cookie', authCookie(admin.id))
.send({ name: 'Updated Name' });
expect(res.status).toBe(200);
expect(res.body.template.name).toBe('Updated Name');
});
it('ADMIN-021 — POST /admin/packing-templates/:id/categories adds a category', async () => {
const { user: admin } = createAdmin(testDb);
const template = await makeTemplate(admin);
const res = await request(app)
.post(`/api/admin/packing-templates/${template.id}/categories`)
.set('Cookie', authCookie(admin.id))
.send({ name: 'Clothing' });
expect(res.status).toBe(201);
expect(res.body.category.name).toBe('Clothing');
});
it('ADMIN-021b — PUT /admin/packing-templates/:templateId/categories/:catId updates category', async () => {
const { user: admin } = createAdmin(testDb);
const template = await makeTemplate(admin);
const catRes = await request(app)
.post(`/api/admin/packing-templates/${template.id}/categories`)
.set('Cookie', authCookie(admin.id))
.send({ name: 'Clothing' });
const catId = catRes.body.category.id;
const res = await request(app)
.put(`/api/admin/packing-templates/${template.id}/categories/${catId}`)
.set('Cookie', authCookie(admin.id))
.send({ name: 'Apparel' });
expect(res.status).toBe(200);
expect(res.body.category.name).toBe('Apparel');
});
it('ADMIN-021c — DELETE /admin/packing-templates/:templateId/categories/:catId removes category', async () => {
const { user: admin } = createAdmin(testDb);
const template = await makeTemplate(admin);
const catRes = await request(app)
.post(`/api/admin/packing-templates/${template.id}/categories`)
.set('Cookie', authCookie(admin.id))
.send({ name: 'Toiletries' });
const catId = catRes.body.category.id;
const res = await request(app)
.delete(`/api/admin/packing-templates/${template.id}/categories/${catId}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('ADMIN-021d — POST .../categories/:catId/items adds an item to category', async () => {
const { user: admin } = createAdmin(testDb);
const template = await makeTemplate(admin);
const catRes = await request(app)
.post(`/api/admin/packing-templates/${template.id}/categories`)
.set('Cookie', authCookie(admin.id))
.send({ name: 'Clothing' });
const catId = catRes.body.category.id;
const res = await request(app)
.post(`/api/admin/packing-templates/${template.id}/categories/${catId}/items`)
.set('Cookie', authCookie(admin.id))
.send({ name: 'T-Shirt' });
expect(res.status).toBe(201);
expect(res.body.item.name).toBe('T-Shirt');
});
it('ADMIN-021e — PUT /admin/packing-templates/:templateId/items/:itemId updates item', async () => {
const { user: admin } = createAdmin(testDb);
const template = await makeTemplate(admin);
const catRes = await request(app)
.post(`/api/admin/packing-templates/${template.id}/categories`)
.set('Cookie', authCookie(admin.id))
.send({ name: 'Clothing' });
const catId = catRes.body.category.id;
const itemRes = await request(app)
.post(`/api/admin/packing-templates/${template.id}/categories/${catId}/items`)
.set('Cookie', authCookie(admin.id))
.send({ name: 'T-Shirt' });
const itemId = itemRes.body.item.id;
const res = await request(app)
.put(`/api/admin/packing-templates/${template.id}/items/${itemId}`)
.set('Cookie', authCookie(admin.id))
.send({ name: 'Polo Shirt' });
expect(res.status).toBe(200);
expect(res.body.item.name).toBe('Polo Shirt');
});
it('ADMIN-021f — DELETE /admin/packing-templates/:templateId/items/:itemId removes item', async () => {
const { user: admin } = createAdmin(testDb);
const template = await makeTemplate(admin);
const catRes = await request(app)
.post(`/api/admin/packing-templates/${template.id}/categories`)
.set('Cookie', authCookie(admin.id))
.send({ name: 'Clothing' });
const catId = catRes.body.category.id;
const itemRes = await request(app)
.post(`/api/admin/packing-templates/${template.id}/categories/${catId}/items`)
.set('Cookie', authCookie(admin.id))
.send({ name: 'T-Shirt' });
const itemId = itemRes.body.item.id;
const res = await request(app)
.delete(`/api/admin/packing-templates/${template.id}/items/${itemId}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// MCP token management
// ─────────────────────────────────────────────────────────────────────────────
describe('MCP token management', () => {
it('ADMIN-023 — GET /admin/mcp-tokens returns list', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/admin/mcp-tokens')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.tokens)).toBe(true);
});
});
+42 -1
View File
@@ -41,7 +41,7 @@ import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, addTripMember } from '../helpers/factories';
import { createUser, createTrip, createDay, createPlace, addTripMember, createTag } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
@@ -261,6 +261,12 @@ describe('Reorder assignments', () => {
.send({ orderedIds: [a2.body.assignment.id, a1.body.assignment.id] });
expect(reorder.status).toBe(200);
expect(reorder.body.success).toBe(true);
const rows = testDb
.prepare('SELECT id, order_index FROM day_assignments WHERE day_id = ? ORDER BY order_index')
.all(day.id) as Array<{ id: number; order_index: number }>;
expect(rows[0].id).toBe(a2.body.assignment.id);
expect(rows[1].id).toBe(a1.body.assignment.id);
});
});
@@ -321,6 +327,41 @@ describe('Assignment participants', () => {
expect(getParticipants.body.participants).toHaveLength(2);
});
it('ASSIGN-010 — GET /assignments includes tags and participants when present', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const { trip, day, place } = setupAssignmentFixtures(user.id);
addTripMember(testDb, trip.id, member.id);
// Attach a tag to the place
const tag = createTag(testDb, user.id, { name: 'Must See' });
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id);
// Create the assignment via API
const create = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id });
expect(create.status).toBe(201);
const assignmentId = create.body.assignment.id;
// Add participants to the assignment
await request(app)
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/participants`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: [user.id, member.id] });
// List assignments — should include tags (compact) and participants
const res = await request(app)
.get(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
const found = (res.body.assignments as any[]).find((a: any) => a.id === assignmentId);
expect(found).toBeDefined();
expect(found.place.tags).toHaveLength(1);
expect(found.participants).toHaveLength(2);
});
it('ASSIGN-009 — PUT /time updates assignment time fields', async () => {
const { user } = createUser(testDb);
const { trip, day, place } = setupAssignmentFixtures(user.id);
+24
View File
@@ -383,3 +383,27 @@ describe('Mark/unmark region', () => {
expect(deRegions).toBeUndefined();
});
});
describe('Regions geo', () => {
it('ATLAS-012 — GET /regions/geo without countries param returns empty FeatureCollection', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/addons/atlas/regions/geo')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toEqual({ type: 'FeatureCollection', features: [] });
});
it('ATLAS-013 — GET /regions/geo?countries=DE,FR returns FeatureCollection', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/addons/atlas/regions/geo?countries=DE,FR')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('type', 'FeatureCollection');
});
});
+131 -1
View File
@@ -1,6 +1,6 @@
/**
* Authentication integration tests.
* Covers AUTH-001 to AUTH-022, AUTH-028 to AUTH-030.
* Covers AUTH-001 to AUTH-022, AUTH-028 to AUTH-033.
* OIDC scenarios (AUTH-023 to AUTH-027) require a real IdP and are excluded.
* Rate limiting scenarios (AUTH-004, AUTH-018) are at the end of this file.
*/
@@ -448,6 +448,67 @@ describe('Short-lived tokens', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Extended scenarios (AUTH-031 to AUTH-033)
// ─────────────────────────────────────────────────────────────────────────────
describe('Extended auth scenarios', () => {
it('AUTH-031 — login succeeds with uppercased email (case-insensitive lookup)', async () => {
const { user, password } = createUser(testDb, { email: 'alice@example.com' });
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'ALICE@EXAMPLE.COM', password });
expect(res.status).toBe(200);
expect(res.body.user).toBeDefined();
});
it('AUTH-032 — registration with duplicate username returns 409', async () => {
createUser(testDb, { username: 'alice' });
const res = await request(app)
.post('/api/auth/register')
.send({ username: 'alice', email: 'alice2@example.com', password: 'Str0ng!Pass' });
expect(res.status).toBe(409);
});
it('AUTH-033 — MFA backup code login succeeds and invalidates the used code', async () => {
const { hashBackupCode, generateBackupCodes } = await import('../../src/services/authService');
const { user, password } = createUserWithMfa(testDb);
// Generate and store backup codes on the MFA-enabled user
const backupCodes = generateBackupCodes();
const backupHashes = backupCodes.map(hashBackupCode);
testDb.prepare('UPDATE users SET mfa_backup_codes = ? WHERE id = ?')
.run(JSON.stringify(backupHashes), user.id);
// Step 1: login to get mfa_token
const loginRes = await request(app)
.post('/api/auth/login')
.send({ email: user.email, password });
expect(loginRes.body.mfa_required).toBe(true);
const { mfa_token } = loginRes.body;
// Step 2: verify with a backup code
const res = await request(app)
.post('/api/auth/mfa/verify-login')
.send({ mfa_token, code: backupCodes[0] });
expect(res.status).toBe(200);
expect(res.body.user).toBeDefined();
// Step 3: same backup code is now consumed — second login attempt fails
const loginRes2 = await request(app)
.post('/api/auth/login')
.send({ email: user.email, password });
const { mfa_token: mfa_token2 } = loginRes2.body;
const res2 = await request(app)
.post('/api/auth/mfa/verify-login')
.send({ mfa_token: mfa_token2, code: backupCodes[0] });
expect(res2.status).toBe(401);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Rate limiting (AUTH-004, AUTH-018) — placed last
// ─────────────────────────────────────────────────────────────────────────────
@@ -478,3 +539,72 @@ describe('Rate limiting', () => {
expect(lastStatus).toBe(429);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// MCP token management (AUTH-034 to AUTH-039)
// ─────────────────────────────────────────────────────────────────────────────
describe('MCP token management', () => {
it('AUTH-034 — GET /auth/mcp-tokens returns empty list initially', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/auth/mcp-tokens')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.tokens).toEqual([]);
});
it('AUTH-035 — POST /auth/mcp-tokens creates a token', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/auth/mcp-tokens')
.set('Cookie', authCookie(user.id))
.send({ name: 'my-token' });
expect(res.status).toBe(201);
expect(res.body.token).toBeDefined();
expect(typeof res.body.token.raw_token).toBe('string');
});
it('AUTH-036 — POST /auth/mcp-tokens without name returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/auth/mcp-tokens')
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(400);
});
it('AUTH-037 — DELETE /auth/mcp-tokens/:id deletes the token', async () => {
const { user } = createUser(testDb);
const createRes = await request(app)
.post('/api/auth/mcp-tokens')
.set('Cookie', authCookie(user.id))
.send({ name: 'to-delete' });
expect(createRes.status).toBe(201);
const tokenId = createRes.body.token.id;
const delRes = await request(app)
.delete(`/api/auth/mcp-tokens/${tokenId}`)
.set('Cookie', authCookie(user.id));
expect(delRes.status).toBe(200);
expect(delRes.body.success).toBe(true);
const listRes = await request(app)
.get('/api/auth/mcp-tokens')
.set('Cookie', authCookie(user.id));
expect(listRes.body.tokens).toEqual([]);
});
it('AUTH-038 — DELETE /auth/mcp-tokens/:id returns 404 for non-existent', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.delete('/api/auth/mcp-tokens/99999')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
it('AUTH-039 — unauthenticated GET /auth/mcp-tokens returns 401', async () => {
const res = await request(app).get('/api/auth/mcp-tokens');
expect(res.status).toBe(401);
});
});
+264
View File
@@ -60,6 +60,12 @@ vi.mock('../../src/services/backupService', async () => {
day_of_week: 0,
day_of_month: 1,
}),
restoreFromZip: vi.fn().mockResolvedValue({ success: true }),
deleteBackup: vi.fn().mockReturnValue(undefined),
backupFileExists: vi.fn().mockReturnValue(false),
backupFilePath: vi.fn().mockReturnValue('/tmp/test-backup.zip'),
// Keep checkRateLimit from actual so rate-limit tests work correctly
checkRateLimit: vi.fn().mockReturnValue(true),
};
});
@@ -70,6 +76,10 @@ import { resetTestDb } from '../helpers/test-db';
import { createAdmin, createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import * as backupService from '../../src/services/backupService';
import fs from 'fs';
import path from 'path';
import os from 'os';
const app: Application = createApp();
@@ -173,3 +183,257 @@ describe('Backup security', () => {
expect(res.status).toBe(404);
});
});
// ---------------------------------------------------------------------------
// Download
// ---------------------------------------------------------------------------
describe('Backup download', () => {
let tmpFile: string;
beforeEach(() => {
// Create a real temporary file that Express can stream back
tmpFile = path.join(os.tmpdir(), `test-backup-${Date.now()}.zip`);
fs.writeFileSync(tmpFile, 'fake zip content');
vi.mocked(backupService.backupFileExists).mockReturnValue(true);
vi.mocked(backupService.backupFilePath).mockReturnValue(tmpFile);
});
afterAll(() => {
try { fs.unlinkSync(tmpFile); } catch {}
});
it('BACKUP-INT-001 — GET /backup/download/:filename returns 200 with content-disposition', async () => {
const { user: admin } = createAdmin(testDb);
const filename = 'backup-2026-04-06T12-00-00.zip';
const res = await request(app)
.get(`/api/backup/download/${filename}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.headers['content-disposition']).toMatch(/attachment/i);
expect(res.headers['content-disposition']).toContain(filename);
});
it('BACKUP-INT-002 — GET /backup/download/:filename returns 400 for invalid filename', async () => {
const { user: admin } = createAdmin(testDb);
vi.mocked(backupService.backupFileExists).mockReturnValue(false);
const res = await request(app)
.get('/api/backup/download/not-a-valid-name.tar.gz')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid filename/i);
});
it('BACKUP-INT-003 — GET /backup/download/:filename returns 404 when file not found', async () => {
const { user: admin } = createAdmin(testDb);
vi.mocked(backupService.backupFileExists).mockReturnValue(false);
const res = await request(app)
.get('/api/backup/download/backup-2026-04-06T12-00-00.zip')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
});
// ---------------------------------------------------------------------------
// Restore from existing backup
// ---------------------------------------------------------------------------
describe('Backup restore', () => {
it('BACKUP-INT-004 — POST /backup/restore/:filename returns 200 on success', async () => {
const { user: admin } = createAdmin(testDb);
const filename = 'backup-2026-04-06T12-00-00.zip';
vi.mocked(backupService.backupFileExists).mockReturnValue(true);
vi.mocked(backupService.restoreFromZip).mockResolvedValue({ success: true });
const res = await request(app)
.post(`/api/backup/restore/${filename}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('BACKUP-INT-005 — POST /backup/restore/:filename returns 404 when backup not found', async () => {
const { user: admin } = createAdmin(testDb);
vi.mocked(backupService.backupFileExists).mockReturnValue(false);
const res = await request(app)
.post('/api/backup/restore/backup-2026-04-06T12-00-00.zip')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('BACKUP-INT-006 — POST /backup/restore/:filename returns 400 for invalid filename', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.post('/api/backup/restore/../../evil.zip')
.set('Cookie', authCookie(admin.id));
// Express resolves path traversal → no route or invalid filename check
expect([400, 404]).toContain(res.status);
});
it('BACKUP-INT-007 — POST /backup/restore/:filename returns 400 when restoreFromZip reports failure', async () => {
const { user: admin } = createAdmin(testDb);
const filename = 'backup-2026-04-06T12-00-00.zip';
vi.mocked(backupService.backupFileExists).mockReturnValue(true);
vi.mocked(backupService.restoreFromZip).mockResolvedValue({
success: false,
error: 'Invalid backup: travel.db not found',
status: 400,
});
const res = await request(app)
.post(`/api/backup/restore/${filename}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/travel\.db not found/i);
});
});
// ---------------------------------------------------------------------------
// Delete backup
// ---------------------------------------------------------------------------
describe('Backup delete', () => {
it('BACKUP-INT-008 — DELETE /backup/:filename returns 200 on success', async () => {
const { user: admin } = createAdmin(testDb);
const filename = 'backup-2026-04-06T12-00-00.zip';
vi.mocked(backupService.backupFileExists).mockReturnValue(true);
vi.mocked(backupService.deleteBackup).mockReturnValue(undefined);
const res = await request(app)
.delete(`/api/backup/${filename}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(vi.mocked(backupService.deleteBackup)).toHaveBeenCalledWith(filename);
});
it('BACKUP-INT-009 — DELETE /backup/:filename returns 404 when not found', async () => {
const { user: admin } = createAdmin(testDb);
vi.mocked(backupService.backupFileExists).mockReturnValue(false);
const res = await request(app)
.delete('/api/backup/backup-2026-04-06T12-00-00.zip')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('BACKUP-INT-010 — DELETE /backup/:filename returns 400 for invalid filename', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.delete('/api/backup/not-a-backup.tar.gz')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid filename/i);
});
});
// ---------------------------------------------------------------------------
// Rate limiter on POST /create
// ---------------------------------------------------------------------------
describe('Backup rate limiter', () => {
it('BACKUP-INT-011 — POST /backup/create returns 429 after 3 requests', async () => {
const { user: admin } = createAdmin(testDb);
// Allow first 3 calls, then block
let callCount = 0;
vi.mocked(backupService.checkRateLimit).mockImplementation(() => {
callCount++;
return callCount <= 3;
});
// First 3 succeed
for (let i = 0; i < 3; i++) {
const res = await request(app)
.post('/api/backup/create')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
}
// 4th is rate-limited
const res = await request(app)
.post('/api/backup/create')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(429);
expect(res.body.error).toMatch(/too many/i);
});
});
// ---------------------------------------------------------------------------
// Upload-restore
// ---------------------------------------------------------------------------
describe('Backup upload-restore', () => {
it('BACKUP-INT-012 — POST /backup/upload-restore with zip file returns 200', async () => {
const { user: admin } = createAdmin(testDb);
vi.mocked(backupService.restoreFromZip).mockResolvedValue({ success: true });
// Create a minimal fake zip buffer (just needs to pass multer's file filter)
const fakeZipBuffer = Buffer.from('PK\x03\x04'); // ZIP magic bytes
const res = await request(app)
.post('/api/backup/upload-restore')
.set('Cookie', authCookie(admin.id))
.attach('backup', fakeZipBuffer, { filename: 'test-restore.zip', contentType: 'application/zip' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(vi.mocked(backupService.restoreFromZip)).toHaveBeenCalled();
});
it('BACKUP-INT-013 — POST /backup/upload-restore with no file returns 400', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.post('/api/backup/upload-restore')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/no file/i);
});
it('BACKUP-INT-014 — POST /backup/upload-restore returns 400 when restore fails', async () => {
const { user: admin } = createAdmin(testDb);
vi.mocked(backupService.restoreFromZip).mockResolvedValue({
success: false,
error: 'Uploaded file is not a valid SQLite database',
status: 400,
});
const fakeZipBuffer = Buffer.from('PK\x03\x04');
const res = await request(app)
.post('/api/backup/upload-restore')
.set('Cookie', authCookie(admin.id))
.attach('backup', fakeZipBuffer, { filename: 'bad-restore.zip', contentType: 'application/zip' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not a valid SQLite/i);
});
});
+80 -5
View File
@@ -209,6 +209,35 @@ describe('Budget item members', () => {
.send({ user_ids: [user.id, member.id] });
expect(res.status).toBe(200);
expect(res.body.members).toBeDefined();
// After assigning members, list items should include them (covers loadBudgetItems member loop)
const listRes = await request(app)
.get(`/api/trips/${trip.id}/budget`)
.set('Cookie', authCookie(user.id));
expect(listRes.status).toBe(200);
const foundItem = (listRes.body.items as any[]).find((i: any) => i.id === item.id);
expect(foundItem).toBeDefined();
expect(foundItem.members).toHaveLength(2);
});
it('BUDGET-005b — PUT /members with empty user_ids clears members', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
// First assign a member
await request(app)
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: [user.id] });
// Then clear members with empty array
const res = await request(app)
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: [] });
expect(res.status).toBe(200);
expect(res.body.members).toHaveLength(0);
});
it('BUDGET-005 — PUT /members with non-array user_ids returns 400', async () => {
@@ -234,12 +263,22 @@ describe('Budget item members', () => {
.set('Cookie', authCookie(user.id))
.send({ user_ids: [user.id] });
// Toggle to paid=true
const res = await request(app)
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
.set('Cookie', authCookie(user.id))
.send({ paid: true });
expect(res.status).toBe(200);
expect(res.body.member).toBeDefined();
expect(res.body.member.paid).toBe(1); // SQLite stores as integer
// Toggle back to paid=false
const res2 = await request(app)
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
.set('Cookie', authCookie(user.id))
.send({ paid: false });
expect(res2.status).toBe(200);
expect(res2.body.member.paid).toBe(0);
});
});
@@ -251,36 +290,72 @@ describe('Budget summary and settlement', () => {
it('BUDGET-007 — GET /summary/per-person returns per-person breakdown', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 });
const item = createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 });
await request(app)
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: [user.id] });
await request(app)
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
.set('Cookie', authCookie(user.id))
.send({ paid: true });
const res = await request(app)
.get(`/api/trips/${trip.id}/budget/summary/per-person`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.summary)).toBe(true);
expect(res.body.summary).toHaveLength(1);
const entry = res.body.summary[0];
expect(entry.user_id).toBe(user.id);
expect(typeof entry.total_paid).toBe('number');
expect(entry.total_paid).toBeGreaterThan(0);
});
it('BUDGET-008 — GET /settlement returns settlement transactions', async () => {
const { user } = createUser(testDb);
const { user: user2 } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, user2.id);
const item = createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 });
await request(app)
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: [user.id, user2.id] });
await request(app)
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
.set('Cookie', authCookie(user.id))
.send({ paid: true });
const res = await request(app)
.get(`/api/trips/${trip.id}/budget/settlement`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('balances');
expect(res.body).toHaveProperty('flows');
expect(Array.isArray(res.body.balances)).toBe(true);
expect(Array.isArray(res.body.flows)).toBe(true);
const payerBalance = res.body.balances.find((b: any) => b.user_id === user.id);
const nonPayerBalance = res.body.balances.find((b: any) => b.user_id === user2.id);
expect(payerBalance.balance).toBeCloseTo(30);
expect(nonPayerBalance.balance).toBeCloseTo(-30);
expect(res.body.flows).toHaveLength(1);
expect(res.body.flows[0].from.user_id).toBe(user2.id);
expect(res.body.flows[0].to.user_id).toBe(user.id);
expect(res.body.flows[0].amount).toBeCloseTo(30);
});
it('BUDGET-009 — settlement with no payers returns empty transactions', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Item with no members/payers assigned
createBudgetItem(testDb, trip.id, { name: 'Train', total_price: 40 });
const res = await request(app)
.get(`/api/trips/${trip.id}/budget/settlement`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.balances).toEqual([]);
expect(res.body.flows).toEqual([]);
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* Categories integration tests — CAT-001 through CAT-009.
* Covers GET/POST/PUT/DELETE /api/categories.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser, createAdmin } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
});
afterAll(() => {
testDb.close();
});
describe('Categories', () => {
it('CAT-001: GET /api/categories returns seeded default categories', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/categories')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.categories)).toBe(true);
// 10 default categories are seeded on reset
expect(res.body.categories.length).toBeGreaterThanOrEqual(10);
expect(res.body.categories[0]).toMatchObject({ name: expect.any(String), color: expect.any(String), icon: expect.any(String) });
});
it('CAT-002: POST /api/categories - admin creates a new category', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.post('/api/categories')
.set('Cookie', authCookie(admin.id))
.send({ name: 'Museum', color: '#7c3aed', icon: '🏛️' });
expect(res.status).toBe(201);
expect(res.body.category).toMatchObject({ name: 'Museum', color: '#7c3aed', icon: '🏛️' });
expect(res.body.category.id).toBeDefined();
});
it('CAT-003: POST /api/categories - non-admin returns 403', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/categories')
.set('Cookie', authCookie(user.id))
.send({ name: 'Museum' });
expect(res.status).toBe(403);
});
it('CAT-004: POST /api/categories - missing name returns 400', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.post('/api/categories')
.set('Cookie', authCookie(admin.id))
.send({ color: '#7c3aed' });
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('CAT-005: PUT /api/categories/:id - admin updates a category', async () => {
const { user: admin } = createAdmin(testDb);
// First create one
const createRes = await request(app)
.post('/api/categories')
.set('Cookie', authCookie(admin.id))
.send({ name: 'Old Name', color: '#aaaaaa', icon: '📌' });
const catId = createRes.body.category.id;
const res = await request(app)
.put(`/api/categories/${catId}`)
.set('Cookie', authCookie(admin.id))
.send({ name: 'New Name', color: '#bbbbbb' });
expect(res.status).toBe(200);
expect(res.body.category.name).toBe('New Name');
expect(res.body.category.color).toBe('#bbbbbb');
// Icon unchanged
expect(res.body.category.icon).toBe('📌');
});
it('CAT-006: PUT /api/categories/:id - non-admin returns 403', async () => {
const { user } = createUser(testDb);
// Get a seeded category id
const cat = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number };
const res = await request(app)
.put(`/api/categories/${cat.id}`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Hacked' });
expect(res.status).toBe(403);
});
it('CAT-007: PUT /api/categories/:id - non-existent category returns 404', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.put('/api/categories/99999')
.set('Cookie', authCookie(admin.id))
.send({ name: 'Ghost' });
expect(res.status).toBe(404);
});
it('CAT-008: DELETE /api/categories/:id - admin deletes a category', async () => {
const { user: admin } = createAdmin(testDb);
const createRes = await request(app)
.post('/api/categories')
.set('Cookie', authCookie(admin.id))
.send({ name: 'To Delete' });
const catId = createRes.body.category.id;
const res = await request(app)
.delete(`/api/categories/${catId}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Verify it's gone
const gone = testDb.prepare('SELECT id FROM categories WHERE id = ?').get(catId);
expect(gone).toBeUndefined();
});
it('CAT-009: DELETE /api/categories/:id - non-admin returns 403', async () => {
const { user } = createUser(testDb);
const cat = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number };
const res = await request(app)
.delete(`/api/categories/${cat.id}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(403);
});
it('CAT-010: GET /api/categories - unauthenticated returns 401', async () => {
const res = await request(app).get('/api/categories');
expect(res.status).toBe(401);
});
});
+146
View File
@@ -42,6 +42,15 @@ vi.mock('../../src/config', () => ({
updateJwtSecret: () => {},
}));
// Partially mock collabService to make fetchLinkPreview controllable
vi.mock('../../src/services/collabService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/services/collabService')>();
return {
...actual,
fetchLinkPreview: vi.fn().mockResolvedValue({ title: null, description: null, image: null, url: '' }),
};
});
import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
@@ -49,6 +58,7 @@ import { resetTestDb } from '../helpers/test-db';
import { createUser, createTrip, addTripMember } from '../helpers/factories';
import { authCookie, generateToken } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import * as collabService from '../../src/services/collabService';
const app: Application = createApp();
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
@@ -637,4 +647,140 @@ describe('Collab validation', () => {
.send({ text: 'A'.repeat(5001) });
expect(res.status).toBe(400);
});
it('COLLAB-008 — poll with fewer than 2 options returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/polls`)
.set('Cookie', authCookie(user.id))
.send({ question: 'Only one option?', options: ['Option A'] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/2 options/i);
});
});
describe('Link preview', () => {
it('COLLAB-025 — GET /collab/link-preview without url returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/collab/link-preview`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/url/i);
});
it('COLLAB-025 — GET /collab/link-preview returns preview for valid URL', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
vi.mocked(collabService.fetchLinkPreview).mockResolvedValueOnce({
title: 'Example Domain',
description: 'A test page',
image: null,
url: 'https://example.com',
});
const res = await request(app)
.get(`/api/trips/${trip.id}/collab/link-preview?url=https://example.com`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.title).toBe('Example Domain');
});
it('COLLAB-026 — GET /collab/link-preview returns 400 when fetchLinkPreview returns error', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
vi.mocked(collabService.fetchLinkPreview).mockResolvedValueOnce({
title: null,
description: null,
image: null,
url: 'http://127.0.0.1',
error: 'Requests to loopback and link-local addresses are not allowed',
} as any);
const res = await request(app)
.get(`/api/trips/${trip.id}/collab/link-preview?url=http://127.0.0.1`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('COLLAB-027 — GET /collab/link-preview catches thrown errors and returns fallback', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
vi.mocked(collabService.fetchLinkPreview).mockRejectedValueOnce(new Error('Unexpected error'));
const res = await request(app)
.get(`/api/trips/${trip.id}/collab/link-preview?url=https://example.com`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.title).toBeNull();
});
});
describe('Message reactions toggle', () => {
it('COLLAB-028 — POST /collab/messages/:msgId/react adds a reaction', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msgRes = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Hello!' });
expect(msgRes.status).toBe(201);
const messageId = msgRes.body.message.id;
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`)
.set('Cookie', authCookie(user.id))
.send({ emoji: '👍' });
expect(res.status).toBe(200);
expect(res.body.reactions).toBeDefined();
const thumbsUp = res.body.reactions.find((r: any) => r.emoji === '👍');
expect(thumbsUp).toBeDefined();
expect(thumbsUp.users.some((u: any) => u.user_id === user.id || u === user.id)).toBe(true);
});
it('COLLAB-029 — POST /collab/messages/:msgId/react on same emoji removes it (toggle)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msgRes = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Toggle me!' });
expect(msgRes.status).toBe(201);
const messageId = msgRes.body.message.id;
// First call — adds the reaction
await request(app)
.post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`)
.set('Cookie', authCookie(user.id))
.send({ emoji: '👍' });
// Second call with same emoji — should toggle it off
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`)
.set('Cookie', authCookie(user.id))
.send({ emoji: '👍' });
expect(res.status).toBe(200);
expect(res.body.reactions).toBeDefined();
const thumbsUp = res.body.reactions.find((r: any) => r.emoji === '👍');
// After toggling off, either the entry is absent or the user is no longer in it
const userStillReacted = thumbsUp && thumbsUp.users && thumbsUp.users.some((u: any) => u.user_id === user.id || u === user.id);
expect(userStillReacted).toBeFalsy();
});
});
+40
View File
@@ -434,6 +434,46 @@ describe('Accommodations', () => {
expect(reservation.confirmation_number).toBe('CONF-XYZ');
});
it('ACCOM-004 — PUT /api/trips/:tripId/accommodations/:id updates the accommodation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
const day1 = createDay(testDb, trip.id, { date: '2026-10-20' });
const day2 = createDay(testDb, trip.id, { date: '2026-10-22' });
const day3 = createDay(testDb, trip.id, { date: '2026-10-25' });
const place = createPlace(testDb, trip.id, { name: 'City Inn' });
const createRes = await request(app)
.post(`/api/trips/${trip.id}/accommodations`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id, notes: 'Original' });
expect(createRes.status).toBe(201);
const accommodationId = createRes.body.accommodation.id;
const updateRes = await request(app)
.put(`/api/trips/${trip.id}/accommodations/${accommodationId}`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day3.id, notes: 'Extended stay' });
expect(updateRes.status).toBe(200);
expect(updateRes.body.accommodation).toBeDefined();
expect(updateRes.body.accommodation.end_day_id).toBe(day3.id);
expect(updateRes.body.accommodation.notes).toBe('Extended stay');
});
it('ACCOM-004 — PUT non-existent accommodation returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const res = await request(app)
.put(`/api/trips/${trip.id}/accommodations/999999`)
.set('Cookie', authCookie(user.id))
.send({ notes: 'Ghost update' });
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('ACCOM-003 — Deleting accommodation also removes the linked reservation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
+1
View File
@@ -77,6 +77,7 @@ beforeEach(() => {
afterAll(() => {
testDb.close();
fs.rmSync(uploadsDir, { recursive: true, force: true });
});
// Helper to upload a file and return the file object
-122
View File
@@ -1,122 +0,0 @@
/**
* Basic smoke test to validate the integration test DB mock pattern.
* Tests MISC-001 — Health check endpoint.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
// ─────────────────────────────────────────────────────────────────────────────
// Step 1: Create a bare in-memory DB instance via vi.hoisted() so it exists
// before the mock factory below runs. Schema setup happens in beforeAll
// (after mocks are registered, so config is mocked when migrations run).
// ─────────────────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: number) => {
const place: any = db.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);
if (!place) return null;
const tags = db.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: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 2: Register mocks BEFORE app is imported (these are hoisted by Vitest)
// ─────────────────────────────────────────────────────────────────────────────
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
// ─────────────────────────────────────────────────────────────────────────────
// Step 3: Import app AFTER mocks (Vitest hoisting ensures mocks are ready first)
// ─────────────────────────────────────────────────────────────────────────────
import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
const app: Application = createApp();
// Schema setup runs here — config is mocked so migrations work correctly
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
describe('Health check', () => {
it('MISC-001 — GET /api/health returns 200 with status ok', async () => {
const res = await request(app).get('/api/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
});
});
describe('Basic auth', () => {
it('AUTH-014 — GET /api/auth/me without session returns 401', async () => {
const res = await request(app).get('/api/auth/me');
expect(res.status).toBe(401);
expect(res.body.code).toBe('AUTH_REQUIRED');
});
it('AUTH-001 — POST /api/auth/login with valid credentials returns 200 + cookie', async () => {
const { user, password } = createUser(testDb);
const res = await request(app)
.post('/api/auth/login')
.send({ email: user.email, password });
expect(res.status).toBe(200);
expect(res.body.user).toMatchObject({ id: user.id, email: user.email });
expect(res.headers['set-cookie']).toBeDefined();
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
? res.headers['set-cookie']
: [res.headers['set-cookie']];
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
});
it('AUTH-014 — authenticated GET /api/auth/me returns user object', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/auth/me')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.user.id).toBe(user.id);
expect(res.body.user.email).toBe(user.email);
});
});
+145
View File
@@ -40,6 +40,18 @@ vi.mock('../../src/config', () => ({
updateJwtSecret: () => {},
}));
// Default mock: resolveGoogleMapsUrl rejects with 400 (SSRF-like behaviour for
// URLs that look internal); individual tests override with mockResolvedValueOnce.
vi.mock('../../src/services/mapsService', () => ({
searchPlaces: vi.fn(),
getPlaceDetails: vi.fn(),
getPlacePhoto: vi.fn(),
reverseGeocode: vi.fn(),
resolveGoogleMapsUrl: vi.fn().mockRejectedValue(
Object.assign(new Error('SSRF or invalid URL'), { status: 400 })
),
}));
import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
@@ -47,6 +59,7 @@ import { resetTestDb } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import * as mapsService from '../../src/services/mapsService';
const app: Application = createApp();
@@ -133,3 +146,135 @@ describe('Maps SSRF protection', () => {
expect(res.status).toBe(400);
});
});
describe('Maps happy paths (mocked service)', () => {
it('MAPS-002 — POST /maps/search returns results from service', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.searchPlaces).mockResolvedValueOnce({
results: [{ address: 'Paris, France', source: 'nominatim' }],
} as any);
const res = await request(app)
.post('/api/maps/search')
.set('Cookie', authCookie(user.id))
.send({ query: 'Paris' });
expect(res.status).toBe(200);
expect(res.body.results).toHaveLength(1);
expect(res.body.results[0].address).toBe('Paris, France');
});
it('MAPS-003 — GET /maps/details/:placeId returns place details', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.getPlaceDetails).mockResolvedValueOnce({
name: 'Eiffel Tower',
address: 'Champ de Mars, Paris',
} as any);
const res = await request(app)
.get('/api/maps/details/ChIJLU7jZClu5kcR4PcOOO6p3I0')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.name).toBe('Eiffel Tower');
});
it('MAPS-004 — GET /maps/place-photo/:placeId returns photo url', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.getPlacePhoto).mockResolvedValueOnce({
url: 'https://example.com/photo.jpg',
source: 'wikimedia',
} as any);
const res = await request(app)
.get('/api/maps/place-photo/ChIJLU7jZClu5kcR4PcOOO6p3I0?lat=48.8584&lng=2.2945')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.url).toBe('https://example.com/photo.jpg');
});
it('MAPS-005 — GET /maps/reverse returns geocoded location', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.reverseGeocode).mockResolvedValueOnce({
name: 'Eiffel Tower',
address: 'Champ de Mars, Paris',
} as any);
const res = await request(app)
.get('/api/maps/reverse?lat=48.8584&lng=2.2945')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.name).toBe('Eiffel Tower');
});
it('MAPS-008 — POST /maps/resolve-url returns extracted coordinates', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.resolveGoogleMapsUrl).mockResolvedValueOnce({
lat: 48.8584,
lng: 2.2945,
} as any);
const res = await request(app)
.post('/api/maps/resolve-url')
.set('Cookie', authCookie(user.id))
.send({ url: 'https://maps.google.com/place/eiffel-tower' });
expect(res.status).toBe(200);
expect(res.body.lat).toBe(48.8584);
expect(res.body.lng).toBe(2.2945);
});
it('MAPS-002 — search service error propagates correct status', async () => {
const { user } = createUser(testDb);
const err = Object.assign(new Error('No API key'), { status: 503 });
vi.mocked(mapsService.searchPlaces).mockRejectedValueOnce(err);
const res = await request(app)
.post('/api/maps/search')
.set('Cookie', authCookie(user.id))
.send({ query: 'Anywhere' });
expect(res.status).toBe(503);
});
it('MAPS-003 — getPlaceDetails error returns 500', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.getPlaceDetails).mockRejectedValueOnce(new Error('External API failure'));
const res = await request(app)
.get('/api/maps/details/some-place-id')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(500);
expect(res.body).toHaveProperty('error');
});
it('MAPS-004 — getPlacePhoto error with status returns that status', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.getPlacePhoto).mockRejectedValueOnce(
Object.assign(new Error('Photo not found'), { status: 404 })
);
const res = await request(app)
.get('/api/maps/place-photo/some-place-id?lat=48.8&lng=2.3')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
expect(res.body).toHaveProperty('error');
});
it('MAPS-005 — reverseGeocode error returns null values', async () => {
const { user } = createUser(testDb);
vi.mocked(mapsService.reverseGeocode).mockRejectedValueOnce(new Error('Geocode failed'));
const res = await request(app)
.get('/api/maps/reverse?lat=48.8584&lng=2.2945')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.name).toBeNull();
expect(res.body.address).toBeNull();
});
});
@@ -511,3 +511,214 @@ describe('Immich auth checks', () => {
expect((await request(app).get(`${IMMICH}/assets/1/asset-x/1/original`)).status).toBe(401);
});
});
// ── Album sync ────────────────────────────────────────────────────────────────
describe('Immich syncAlbumAssets', () => {
it('IMMICH-080 — POST sync happy path: trip owner with album link saves photos to DB', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024');
const res = await request(app)
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(typeof res.body.total).toBe('number');
expect(typeof res.body.added).toBe('number');
// Verify photos were inserted into the DB
const photos = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ?').all(trip.id, user.id) as any[];
expect(photos.length).toBeGreaterThan(0);
expect(photos[0].provider).toBe('immich');
});
it('IMMICH-081 — POST sync when user is not a trip member returns 404', async () => {
const { user: owner } = createUser(testDb);
const { user: outsider } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key');
const link = addAlbumLink(testDb, trip.id, owner.id, 'immich', 'album-uuid-1', 'Vacation 2024');
// outsider is not a trip member — getAlbumIdFromLink checks canAccessTrip
const res = await request(app)
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(outsider.id));
expect(res.status).toBe(404);
});
it('IMMICH-082 — POST sync when Immich is not configured returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// No Immich credentials set — but still need a valid album link owned by user
const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024');
const res = await request(app)
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('IMMICH-083 — POST sync when safeFetch throws returns 502', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024');
vi.mocked(safeFetch).mockRejectedValueOnce(new Error('network failure during sync'));
const res = await request(app)
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(502);
expect(res.body.error).toBeDefined();
});
it('IMMICH-084 — POST sync when album link does not belong to requesting user returns 404', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
setImmichCredentials(testDb, member.id, 'https://immich.example.com', 'test-api-key');
// Album link is owned by owner, not member
const link = addAlbumLink(testDb, trip.id, owner.id, 'immich', 'album-uuid-1', 'Vacation 2024');
// member is a trip member but the album link belongs to owner — getAlbumIdFromLink checks user_id
const res = await request(app)
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(404);
});
it('IMMICH-085 — POST sync without auth returns 401', async () => {
expect((await request(app).post(`${IMMICH}/trips/1/album-links/1/sync`)).status).toBe(401);
});
});
// ── searchPhotos pagination safety ────────────────────────────────────────────
describe('Immich searchPhotos pagination safety', () => {
it('IMMICH-090 — searchPhotos stops at page 20 when hasMore is always true', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
// Return a full page of 1000 items on every call, so the loop would
// run indefinitely without the page > 20 safety check.
const fullPageResponse = {
ok: true, status: 200,
headers: { get: () => null },
json: () => Promise.resolve({
assets: {
items: Array.from({ length: 1000 }, (_, i) => ({
id: `asset-${i}`,
fileCreatedAt: '2024-06-01T10:00:00.000Z',
exifInfo: { city: 'Paris', country: 'France' },
})),
},
}),
body: null,
} as any;
// Clear previous call history so the count only reflects this test
vi.mocked(safeFetch).mockClear();
vi.mocked(safeFetch).mockResolvedValue(fullPageResponse);
const res = await request(app)
.post(`${IMMICH}/search`)
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(200);
expect(Array.isArray(res.body.assets)).toBe(true);
// 20 pages × 1000 items = 20000 assets total (safety limit)
expect(res.body.assets.length).toBe(20000);
// safeFetch should have been called exactly 20 times (the safety limit)
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(20);
});
});
// ── saveImmichSettings clearing credentials ───────────────────────────────────
describe('Immich saveImmichSettings clearing URL', () => {
it('IMMICH-095 — PUT /settings with no URL clears immich_url but preserves (updates) api key', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'old-key');
// Send without immich_url to trigger the else branch (clear URL path)
const res = await request(app)
.put(`${IMMICH}/settings`)
.set('Cookie', authCookie(user.id))
.send({ immich_api_key: 'new-key' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const row = testDb.prepare('SELECT immich_url FROM users WHERE id = ?').get(user.id) as any;
expect(row.immich_url).toBeNull();
});
it('IMMICH-096 — PUT /settings with empty string URL clears immich_url', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'old-key');
const res = await request(app)
.put(`${IMMICH}/settings`)
.set('Cookie', authCookie(user.id))
.send({ immich_url: '', immich_api_key: 'old-key' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const row = testDb.prepare('SELECT immich_url FROM users WHERE id = ?').get(user.id) as any;
expect(row.immich_url).toBeNull();
});
});
// ── testConnection canonical URL detection ────────────────────────────────────
describe('Immich testConnection canonical URL detection', () => {
it('IMMICH-100 — POST /test with http URL that gets upgraded to https returns canonicalUrl', async () => {
const { user } = createUser(testDb);
// Mock safeFetch so the response.url reflects https upgrade
vi.mocked(safeFetch).mockResolvedValueOnce({
ok: true,
status: 200,
url: 'https://immich.example.com/api/users/me',
headers: { get: (h: string) => h === 'content-type' ? 'application/json' : null } as any,
json: async () => ({ name: 'Redirect User', email: 'redirect@immich.local' }),
body: null,
} as any);
const res = await request(app)
.post(`${IMMICH}/test`)
.set('Cookie', authCookie(user.id))
.send({ immich_url: 'http://immich.example.com', immich_api_key: 'valid-key' });
expect(res.status).toBe(200);
expect(res.body.connected).toBe(true);
expect(res.body.canonicalUrl).toBe('https://immich.example.com');
});
it('IMMICH-101 — POST /test with https URL that stays https does not return canonicalUrl', async () => {
const { user } = createUser(testDb);
// The default mock returns a response without .url property — no upgrade
const res = await request(app)
.post(`${IMMICH}/test`)
.set('Cookie', authCookie(user.id))
.send({ immich_url: 'https://immich.example.com', immich_api_key: 'valid-key' });
expect(res.status).toBe(200);
expect(res.body.connected).toBe(true);
expect(res.body.canonicalUrl).toBeUndefined();
});
});
@@ -543,3 +543,322 @@ describe('Synology auth checks', () => {
expect((await request(app).get(`${SYNO}/assets/1/photo-x/1/thumbnail`)).status).toBe(401);
});
});
// ── Album sync ────────────────────────────────────────────────────────────────
import { addAlbumLink } from '../helpers/factories';
describe('Synology syncSynologyAlbumLink', () => {
it('SYNO-050 — POST sync happy path: trip owner with album link saves photos to DB', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
// The migration inserts synologyphotos with enabled=0; ensure it is enabled for this test.
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
// album_id must be a numeric string so getAlbumIdFromLink returns it and
// syncSynologyAlbumLink passes Number(album_id) to the API.
const link = addAlbumLink(testDb, trip.id, user.id, 'synologyphotos', '1', 'Summer Trip');
const res = await request(app)
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(typeof res.body.added).toBe('number');
expect(typeof res.body.total).toBe('number');
// Verify photos were inserted into the DB
const photos = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ?').all(trip.id, user.id) as any[];
expect(photos.length).toBeGreaterThan(0);
expect(photos[0].provider).toBe('synologyphotos');
});
it('SYNO-051 — POST sync when user is not a trip member returns 404', async () => {
const { user: owner } = createUser(testDb);
const { user: outsider } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
setSynologyCredentials(testDb, owner.id, 'https://synology.example.com', 'admin', 'pass');
const link = addAlbumLink(testDb, trip.id, owner.id, 'synologyphotos', '1', 'Summer Trip');
const res = await request(app)
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(outsider.id));
expect(res.status).toBe(404);
});
it('SYNO-052 — POST sync when Synology is not configured returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// No credentials — album link still exists for the user
const link = addAlbumLink(testDb, trip.id, user.id, 'synologyphotos', '1', 'Summer Trip');
const res = await request(app)
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('SYNO-053 — POST sync without auth returns 401', async () => {
expect((await request(app).post(`${SYNO}/trips/1/album-links/1/sync`)).status).toBe(401);
});
});
// ── Session retry logic ───────────────────────────────────────────────────────
describe('Synology session retry on error codes 106/107/119', () => {
it('SYNO-060 — request retries with fresh session when API returns error code 119', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
// Clear previous call history so the count only reflects this test's calls
vi.mocked(safeFetch).mockClear();
// Call sequence:
// 1. Auth login (fresh session — no cached SID) → success with sid
// 2. SYNO.Foto.Browse.Album call → returns { success: false, error: { code: 119 } }
// 3. Auth login again (retry session after clearing SID) → success with new sid
// 4. SYNO.Foto.Browse.Album retry call → success
vi.mocked(safeFetch)
.mockResolvedValueOnce({
// call 1: initial login
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'first-sid' } }),
body: null,
} as any)
.mockResolvedValueOnce({
// call 2: album list → session expired (119)
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: false, error: { code: 119 } }),
body: null,
} as any)
.mockResolvedValueOnce({
// call 3: retry login after clearing SID
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'second-sid' } }),
body: null,
} as any)
.mockResolvedValueOnce({
// call 4: retry album list → success
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({
success: true,
data: {
list: [{ id: 99, name: 'Retry Album', item_count: 5 }],
},
}),
body: null,
} as any);
const res = await request(app)
.get(`${SYNO}/albums`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.albums)).toBe(true);
expect(res.body.albums[0]).toMatchObject({ albumName: 'Retry Album' });
// Four safeFetch calls: login, failed album list, re-login, successful album list
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4);
});
it('SYNO-061 — request retries with fresh session when API returns error code 106', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
vi.mocked(safeFetch).mockClear();
vi.mocked(safeFetch)
.mockResolvedValueOnce({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'sid-one' } }),
body: null,
} as any)
.mockResolvedValueOnce({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: false, error: { code: 106 } }),
body: null,
} as any)
.mockResolvedValueOnce({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'sid-two' } }),
body: null,
} as any)
.mockResolvedValueOnce({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({
success: true,
data: { list: [{ id: 3, name: 'Timeout Album', item_count: 2 }] },
}),
body: null,
} as any);
const res = await request(app)
.get(`${SYNO}/albums`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.albums[0]).toMatchObject({ albumName: 'Timeout Album' });
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4);
});
});
// ── Date range search ─────────────────────────────────────────────────────────
describe('Synology searchSynologyPhotos date range', () => {
it('SYNO-070 — POST /search with from/to passes start_time and end_time to Synology API', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
// Capture the body sent on the search call (second safeFetch call after auth)
let capturedBody: URLSearchParams | null = null;
vi.mocked(safeFetch)
.mockResolvedValueOnce({
// login
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
body: null,
} as any)
.mockImplementationOnce((_url: string, init?: any) => {
capturedBody = init?.body instanceof URLSearchParams
? init.body
: new URLSearchParams(String(init?.body ?? ''));
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({
success: true,
data: {
list: [
{
id: 201,
filename: 'dated.jpg',
filesize: 512000,
time: 1717228800,
additional: {
thumbnail: { cache_key: '201_abc' },
address: { city: 'Kyoto', country: 'Japan', state: 'Kyoto' },
exif: {},
gps: {},
resolution: { width: 4000, height: 3000 },
orientation: 1,
description: null,
},
},
],
},
}),
body: null,
} as any);
});
const res = await request(app)
.post(`${SYNO}/search`)
.set('Cookie', authCookie(user.id))
.send({ from: '2024-06-01', to: '2024-06-30' });
expect(res.status).toBe(200);
expect(Array.isArray(res.body.assets)).toBe(true);
// Verify date parameters were forwarded in the Synology API request body
expect(capturedBody).not.toBeNull();
const startTime = capturedBody!.get('start_time');
const endTime = capturedBody!.get('end_time');
expect(startTime).toBeDefined();
expect(Number(startTime)).toBeGreaterThan(0);
expect(endTime).toBeDefined();
expect(Number(endTime)).toBeGreaterThan(Number(startTime));
});
it('SYNO-071 — POST /search without date range omits start_time and end_time', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
let capturedBody: URLSearchParams | null = null;
vi.mocked(safeFetch)
.mockResolvedValueOnce({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
body: null,
} as any)
.mockImplementationOnce((_url: string, init?: any) => {
capturedBody = init?.body instanceof URLSearchParams
? init.body
: new URLSearchParams(String(init?.body ?? ''));
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { list: [] } }),
body: null,
} as any);
});
const res = await request(app)
.post(`${SYNO}/search`)
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(200);
expect(capturedBody).not.toBeNull();
expect(capturedBody!.get('start_time')).toBeNull();
expect(capturedBody!.get('end_time')).toBeNull();
});
});
// ── SSRF catch branch in _fetchSynologyJson ────────────────────────────────────
describe('Synology SSRF blocked error handling', () => {
it('SYNO-080 — safeFetch throwing SsrfBlockedError for private IP URL returns connected: false', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'http://192.168.1.200', 'admin', 'pass');
const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard');
// Make safeFetch throw SsrfBlockedError — simulating the SSRF guard blocking the private IP.
// _fetchSynologyJson catches SsrfBlockedError and returns fail(message, 400).
// getSynologyStatus receives the failure from _getSynologySession and returns { connected: false }.
vi.mocked(safeFetch).mockRejectedValueOnce(new SsrfErr('Private IP not allowed'));
const res = await request(app)
.get(`${SYNO}/status`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.connected).toBe(false);
});
it('SYNO-081 — safeFetch throwing SsrfBlockedError during album list returns 400', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard');
// Auth succeeds, but the album-list call throws SsrfBlockedError
vi.mocked(safeFetch)
.mockResolvedValueOnce({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'sid-x' } }),
body: null,
} as any)
.mockRejectedValueOnce(new SsrfErr('Private IP detected'));
const res = await request(app)
.get(`${SYNO}/albums`)
.set('Cookie', authCookie(user.id));
// _fetchSynologyJson catches SsrfBlockedError and returns fail(message, 400)
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
});
-21
View File
@@ -119,24 +119,3 @@ describe('Force HTTPS redirect', () => {
});
});
describe('Categories endpoint', () => {
it('MISC-013/PLACE-015 — GET /api/categories returns seeded categories', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/categories')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.categories)).toBe(true);
expect(res.body.categories.length).toBeGreaterThan(0);
});
});
describe('App config', () => {
it('MISC-015 — GET /api/auth/app-config returns configuration', async () => {
const res = await request(app).get('/api/auth/app-config');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('allow_registration');
expect(res.body).toHaveProperty('oidc_configured');
});
});
@@ -40,6 +40,14 @@ vi.mock('../../src/config', () => ({
updateJwtSecret: () => {},
}));
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
vi.mock('../../src/services/notifications', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/services/notifications')>();
return {
...actual,
testSmtp: vi.fn().mockResolvedValue({ success: true }),
testWebhook: vi.fn().mockResolvedValue({ success: true }),
};
});
import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
@@ -316,6 +324,30 @@ describe('Notification test endpoints', () => {
.send({ url: 'not-a-url' });
expect(res.status).toBe(400);
});
it('NOTIF-005b — admin can call test-smtp and gets a result', async () => {
const { user } = createAdmin(testDb);
const res = await request(app)
.post('/api/notifications/test-smtp')
.set('Cookie', authCookie(user.id))
.send({ email: 'test@example.com' });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('success');
});
it('NOTIF-006c — POST /api/notifications/test-webhook with valid URL calls testWebhook', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/notifications/test-webhook')
.set('Cookie', authCookie(user.id))
.send({ url: 'https://webhook.site/test-endpoint' });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('success');
});
});
// ─────────────────────────────────────────────────────────────────────────────
+282
View File
@@ -0,0 +1,282 @@
/**
* OIDC integration tests OIDC-001 through OIDC-010.
* Covers /api/auth/oidc/login, /callback, /exchange.
* HTTP calls (discover, exchangeCodeForToken, getUserInfo) are mocked.
* State management, auth codes, and findOrCreateUser run against the real test DB.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
// ── DB mock (inline vi.hoisted pattern) ──────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
// ── Mock only the HTTP-calling functions from oidcService ────────────────────
vi.mock('../../src/services/oidcService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/services/oidcService')>();
return {
...actual,
discover: vi.fn(),
exchangeCodeForToken: vi.fn(),
getUserInfo: vi.fn(),
};
});
import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import * as oidcService from '../../src/services/oidcService';
const mockDiscover = vi.mocked(oidcService.discover);
const mockExchangeCode = vi.mocked(oidcService.exchangeCodeForToken);
const mockGetUserInfo = vi.mocked(oidcService.getUserInfo);
const MOCK_DISCOVERY_DOC = {
authorization_endpoint: 'https://oidc.example.com/auth',
token_endpoint: 'https://oidc.example.com/token',
userinfo_endpoint: 'https://oidc.example.com/userinfo',
};
const app: Application = createApp();
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
vi.clearAllMocks();
// Set OIDC environment variables for each test
process.env.OIDC_ISSUER = 'https://oidc.example.com';
process.env.OIDC_CLIENT_ID = 'test-client-id';
process.env.OIDC_CLIENT_SECRET = 'test-client-secret';
process.env.APP_URL = 'http://localhost:3001';
});
afterEach(() => {
delete process.env.OIDC_ISSUER;
delete process.env.OIDC_CLIENT_ID;
delete process.env.OIDC_CLIENT_SECRET;
delete process.env.APP_URL;
});
afterAll(() => {
testDb.close();
});
// ── /login ───────────────────────────────────────────────────────────────────
describe('GET /api/auth/oidc/login', () => {
it('OIDC-001: redirects to OIDC authorization endpoint (302)', async () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
const res = await request(app).get('/api/auth/oidc/login');
expect(res.status).toBe(302);
expect(res.headers.location).toContain('https://oidc.example.com/auth');
expect(res.headers.location).toContain('client_id=test-client-id');
expect(res.headers.location).toContain('response_type=code');
expect(res.headers.location).toContain('redirect_uri=');
expect(res.headers.location).toContain('state=');
});
it('OIDC-002: returns 400 when OIDC is not configured', async () => {
delete process.env.OIDC_ISSUER;
delete process.env.OIDC_CLIENT_ID;
delete process.env.OIDC_CLIENT_SECRET;
const res = await request(app).get('/api/auth/oidc/login');
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('OIDC-003: includes invite token in state when provided', async () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
const res = await request(app).get('/api/auth/oidc/login?invite=abc123');
expect(res.status).toBe(302);
// State is a hex token; the invite is embedded in pendingStates (internal)
// We just verify the redirect happened successfully
expect(res.headers.location).toContain('state=');
});
});
// ── /callback ────────────────────────────────────────────────────────────────
describe('GET /api/auth/oidc/callback', () => {
it('OIDC-004: valid code for existing user → redirects to frontend with oidc_code', async () => {
const { user } = createUser(testDb, { email: 'alice@example.com' });
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({
access_token: 'test-access-token',
_ok: true,
_status: 200,
});
mockGetUserInfo.mockResolvedValueOnce({
sub: 'sub-alice-123',
email: 'alice@example.com',
name: 'Alice',
});
// Create a valid state token
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=authcode123&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('/login?oidc_code=');
});
it('OIDC-005: new user gets created when registration is open', async () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'new-token', _ok: true, _status: 200 });
mockGetUserInfo.mockResolvedValueOnce({
sub: 'sub-newuser-999',
email: 'newuser@example.com',
name: 'New User',
});
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=code999&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('/login?oidc_code=');
// Verify user was created in DB
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get();
expect(newUser).toBeDefined();
});
it('OIDC-006: invalid state → redirects with invalid_state error', async () => {
const res = await request(app).get('/api/auth/oidc/callback?code=abc&state=invalid-state-xyz');
expect(res.status).toBe(302);
expect(res.headers.location).toContain('oidc_error=invalid_state');
});
it('OIDC-007: provider error param → redirects with error', async () => {
const res = await request(app).get('/api/auth/oidc/callback?error=access_denied');
expect(res.status).toBe(302);
expect(res.headers.location).toContain('oidc_error=access_denied');
});
it('OIDC-008: missing code or state → redirects with missing_params error', async () => {
const res = await request(app).get('/api/auth/oidc/callback');
expect(res.status).toBe(302);
expect(res.headers.location).toContain('oidc_error=missing_params');
});
it('OIDC-009: token exchange failure → redirects with token_failed error', async () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ _ok: false, _status: 400 });
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=badcode&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('oidc_error=token_failed');
});
it('OIDC-010: registration disabled for new user → redirects with registration_disabled error', async () => {
// Need at least one existing user so isFirstUser=false
createUser(testDb, { email: 'existing@example.com' });
// Disable registration
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', _ok: true, _status: 200 });
mockGetUserInfo.mockResolvedValueOnce({
sub: 'sub-blocked-user',
email: 'blocked@example.com',
name: 'Blocked',
});
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('oidc_error=registration_disabled');
});
});
// ── /exchange ─────────────────────────────────────────────────────────────────
describe('GET /api/auth/oidc/exchange', () => {
it('OIDC-011: valid auth code returns JWT and sets cookie', async () => {
const fakeToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.sig';
const code = oidcService.createAuthCode(fakeToken);
const res = await request(app).get(`/api/auth/oidc/exchange?code=${code}`);
expect(res.status).toBe(200);
expect(res.body.token).toBe(fakeToken);
expect(res.headers['set-cookie']).toBeDefined();
const cookieHeader = Array.isArray(res.headers['set-cookie'])
? res.headers['set-cookie'].join(';')
: res.headers['set-cookie'];
expect(cookieHeader).toContain('trek_session');
});
it('OIDC-012: missing code returns 400', async () => {
const res = await request(app).get('/api/auth/oidc/exchange');
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('OIDC-013: invalid/expired code returns 400', async () => {
const res = await request(app).get('/api/auth/oidc/exchange?code=not-a-real-code');
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('OIDC-014: auth code is single-use (second use returns 400)', async () => {
const fakeToken = 'test.token.here';
const code = oidcService.createAuthCode(fakeToken);
// First use: success
const res1 = await request(app).get(`/api/auth/oidc/exchange?code=${code}`);
expect(res1.status).toBe(200);
// Second use: rejected
const res2 = await request(app).get(`/api/auth/oidc/exchange?code=${code}`);
expect(res2.status).toBe(400);
});
});
+123
View File
@@ -244,6 +244,12 @@ describe('Reorder packing items', () => {
.send({ orderedIds: [i2.id, i1.id] });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const rows = testDb
.prepare('SELECT id, sort_order FROM packing_items WHERE trip_id = ? ORDER BY sort_order')
.all(trip.id) as Array<{ id: number; sort_order: number }>;
expect(rows[0].id).toBe(i2.id);
expect(rows[1].id).toBe(i1.id);
});
});
@@ -360,3 +366,120 @@ describe('Category assignees', () => {
expect(res.body.assignees).toBeDefined();
});
});
describe('Packing — apply-template, bag members, save-as-template', () => {
it('PACK-015 — POST /apply-template/:templateId applies template items to trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Beach', ?)").run(user.id);
const cat = testDb.prepare("INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, 'Essentials', 0)").run(tpl.lastInsertRowid);
testDb.prepare("INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, 'Sunscreen', 0)").run(cat.lastInsertRowid);
const templateId = tpl.lastInsertRowid;
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/apply-template/${templateId}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.items)).toBe(true);
expect(res.body.items.length).toBeGreaterThan(0);
expect(res.body.count).toBeGreaterThan(0);
});
it('PACK-015b — POST /apply-template/:id for empty template returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Template with no items
const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Empty', ?)").run(user.id);
const emptyTemplateId = tpl.lastInsertRowid;
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/apply-template/${emptyTemplateId}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
expect(res.body.error).toBeDefined();
});
it('PACK-016 — PUT /bags/:bagId/members sets bag members', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
// Create a bag first
const bagRes = await request(app)
.post(`/api/trips/${trip.id}/packing/bags`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Carry-on' });
expect(bagRes.status).toBe(201);
const bagId = bagRes.body.bag.id;
const res = await request(app)
.put(`/api/trips/${trip.id}/packing/bags/${bagId}/members`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: [user.id, member.id] });
expect(res.status).toBe(200);
expect(Array.isArray(res.body.members)).toBe(true);
expect(res.body.members.length).toBe(2);
});
it('PACK-016b — PUT /bags/:bagId/members for non-existent bag returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/packing/bags/999999/members`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: [user.id] });
expect(res.status).toBe(404);
expect(res.body.error).toBeDefined();
});
it('PACK-017 — POST /save-as-template saves packing list as a template', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Add an item so the trip has something to save
createPackingItem(testDb, trip.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/save-as-template`)
.set('Cookie', authCookie(user.id))
.send({ name: 'My Summer Template' });
expect(res.status).toBe(201);
expect(res.body.template).toBeDefined();
expect(res.body.template.name).toBe('My Summer Template');
});
it('PACK-017b — POST /save-as-template without name returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/save-as-template`)
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('PACK-017c — POST /save-as-template when trip has no items returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/save-as-template`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Empty Trip Template' });
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
});
+187
View File
@@ -42,6 +42,14 @@ vi.mock('../../src/config', () => ({
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../src/services/placeService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/services/placeService')>();
return {
...actual,
importGoogleList: vi.fn(),
searchPlaceImage: vi.fn(),
};
});
import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
@@ -50,6 +58,8 @@ import { resetTestDb } from '../helpers/test-db';
import { createUser, createAdmin, createTrip, createPlace, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import * as placeService from '../../src/services/placeService';
import { invalidatePermissionsCache } from '../../src/services/permissions';
const app: Application = createApp();
const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx');
@@ -63,6 +73,7 @@ beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
invalidatePermissionsCache();
});
afterAll(() => {
@@ -603,3 +614,179 @@ describe('GPX Import', () => {
expect(res.status).toBe(400);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// GPX import — no waypoints
// ─────────────────────────────────────────────────────────────────────────────
describe('GPX Import — edge cases', () => {
it('PLACE-019c — GPX with no waypoints returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Minimal valid GPX with no waypoints, tracks, or routes
const emptyGpx = Buffer.from(
'<?xml version="1.0" encoding="UTF-8"?>' +
'<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"></gpx>'
);
const res = await request(app)
.post(`/api/trips/${trip.id}/places/import/gpx`)
.set('Cookie', authCookie(user.id))
.attach('file', emptyGpx, { filename: 'empty.gpx', contentType: 'application/gpx+xml' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/no waypoints/i);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Google Maps list import
// ─────────────────────────────────────────────────────────────────────────────
describe('Google Maps list import', () => {
it('PLACE-020 — POST /import/google-list without url returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/places/import/google-list`)
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(400);
});
it('PLACE-020b — POST /import/google-list success path returns 201 with places', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
vi.mocked(placeService.importGoogleList).mockResolvedValueOnce({
places: [{ id: 1, name: 'Mocked Place' } as any],
listName: 'My List',
} as any);
const res = await request(app)
.post(`/api/trips/${trip.id}/places/import/google-list`)
.set('Cookie', authCookie(user.id))
.send({ url: 'https://maps.google.com/maps/list/example' });
expect(res.status).toBe(201);
expect(res.body.count).toBe(1);
expect(res.body.listName).toBe('My List');
});
it('PLACE-020c — POST /import/google-list returns service error status', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
vi.mocked(placeService.importGoogleList).mockResolvedValueOnce({
error: 'Invalid list URL',
status: 422,
} as any);
const res = await request(app)
.post(`/api/trips/${trip.id}/places/import/google-list`)
.set('Cookie', authCookie(user.id))
.send({ url: 'https://maps.google.com/maps/list/bad' });
expect(res.status).toBe(422);
expect(res.body.error).toBe('Invalid list URL');
});
it('PLACE-020d — POST /import/google-list thrown exception returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
vi.mocked(placeService.importGoogleList).mockRejectedValueOnce(new Error('Network failure'));
const res = await request(app)
.post(`/api/trips/${trip.id}/places/import/google-list`)
.set('Cookie', authCookie(user.id))
.send({ url: 'https://maps.google.com/maps/list/broken' });
expect(res.status).toBe(400);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Place image search
// ─────────────────────────────────────────────────────────────────────────────
describe('Place image search', () => {
it('PLACE-021 — GET /:id/image returns photos on success', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
vi.mocked(placeService.searchPlaceImage).mockResolvedValueOnce({
photos: [{ url: 'https://example.com/photo.jpg' }],
} as any);
const res = await request(app)
.get(`/api/trips/${trip.id}/places/${place.id}/image`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.photos).toHaveLength(1);
});
it('PLACE-021b — GET /:id/image returns service error status', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Tower' });
vi.mocked(placeService.searchPlaceImage).mockResolvedValueOnce({
error: 'No images found',
status: 404,
} as any);
const res = await request(app)
.get(`/api/trips/${trip.id}/places/${place.id}/image`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
expect(res.body.error).toBe('No images found');
});
it('PLACE-021c — GET /:id/image thrown exception returns 500', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Bridge' });
vi.mocked(placeService.searchPlaceImage).mockRejectedValueOnce(new Error('Unsplash down'));
const res = await request(app)
.get(`/api/trips/${trip.id}/places/${place.id}/image`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(500);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Delete place permission denied
// ─────────────────────────────────────────────────────────────────────────────
describe('Delete place — permission edge cases', () => {
it('PLACE-022 — DELETE place by non-owner member when place_edit is trip_owner returns 403', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const place = createPlace(testDb, trip.id, { name: 'Restricted Place' });
// Restrict place edits to trip owner only
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_place_edit', 'trip_owner')").run();
invalidatePermissionsCache();
const res = await request(app)
.delete(`/api/trips/${trip.id}/places/${place.id}`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(403);
});
});
describe('Delete place — not found', () => {
it('PLACE-023 — DELETE non-existent place returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.delete(`/api/trips/${trip.id}/places/99999`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
});
-30
View File
@@ -205,36 +205,6 @@ describe('Settings', () => {
});
});
describe('API Keys', () => {
it('PROFILE-011 — PUT /api/auth/me/api-keys saves keys encrypted at rest', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put('/api/auth/me/api-keys')
.set('Cookie', authCookie(user.id))
.send({ openweather_api_key: 'my-weather-key-123' });
expect(res.status).toBe(200);
// Key in DB should be encrypted (not plaintext)
const row = testDb.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(user.id) as any;
expect(row.openweather_api_key).toMatch(/^enc:v1:/);
});
it('PROFILE-011 — GET /api/auth/me does not return plaintext API keys', async () => {
const { user } = createUser(testDb);
await request(app)
.put('/api/auth/me/api-keys')
.set('Cookie', authCookie(user.id))
.send({ openweather_api_key: 'plaintext-key' });
const me = await request(app)
.get('/api/auth/me')
.set('Cookie', authCookie(user.id));
// The key should be masked or absent, never plaintext
const body = me.body.user;
expect(body.openweather_api_key).not.toBe('plaintext-key');
});
});
describe('Account deletion', () => {
it('PROFILE-013 — DELETE /api/auth/me removes account, subsequent login fails', async () => {
const { user, password } = createUser(testDb);
+213 -1
View File
@@ -41,7 +41,7 @@ import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser, createTrip, createDay, createReservation, addTripMember } from '../helpers/factories';
import { createUser, createTrip, createDay, createPlace, createReservation, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
@@ -187,6 +187,43 @@ describe('Update reservation', () => {
.send({ title: 'Updated' });
expect(res.status).toBe(404);
});
it('RESV-010 — PUT syncs check-in/out times to linked accommodation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day1 = createDay(testDb, trip.id, { date: '2025-08-01' });
const day2 = createDay(testDb, trip.id, { date: '2025-08-03' });
const place = createPlace(testDb, trip.id, { name: 'Sync Hotel' });
// Create reservation with linked accommodation
const createRes = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({
title: 'Hotel Booking',
type: 'hotel',
day_id: day1.id,
create_accommodation: { place_id: place.id, start_day_id: day1.id, end_day_id: day2.id },
});
expect(createRes.status).toBe(201);
const resvId = createRes.body.reservation.id;
// Update with metadata containing check-in/out times and confirmation_number
const updateRes = await request(app)
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
.set('Cookie', authCookie(user.id))
.send({
metadata: { check_in_time: '15:00', check_out_time: '11:00' },
confirmation_number: 'HTL-XYZ-999',
});
expect(updateRes.status).toBe(200);
// Verify accommodation was updated with check-in/out
const accom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').get(trip.id) as any;
expect(accom.check_in).toBe('15:00');
expect(accom.check_out).toBe('11:00');
expect(accom.confirmation).toBe('HTL-XYZ-999');
});
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -241,3 +278,178 @@ describe('Batch update positions', () => {
expect(res.body.success).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Budget entry auto-create / auto-update
// ─────────────────────────────────────────────────────────────────────────────
describe('Reservation budget entry integration', () => {
it('RESV-011 — POST with create_budget_entry auto-creates a linked budget item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({
title: 'Flight to Paris',
type: 'flight',
create_budget_entry: { total_price: 250, category: 'Transport' },
});
expect(res.status).toBe(201);
const budgetItem = testDb
.prepare('SELECT * FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(trip.id, res.body.reservation.id) as any;
expect(budgetItem).toBeDefined();
expect(budgetItem.total_price).toBe(250);
expect(budgetItem.name).toBe('Flight to Paris');
});
it('RESV-011b — POST with create_budget_entry.total_price = 0 skips budget creation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({
title: 'Free Entry',
type: 'activity',
create_budget_entry: { total_price: 0 },
});
expect(res.status).toBe(201);
const budgetItems = testDb
.prepare('SELECT * FROM budget_items WHERE trip_id = ?')
.all(trip.id) as any[];
expect(budgetItems).toHaveLength(0);
});
it('RESV-012 — PUT with create_budget_entry creates a new budget item when none exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const resv = createReservation(testDb, trip.id, { title: 'Hotel Stay', type: 'hotel' });
const res = await request(app)
.put(`/api/trips/${trip.id}/reservations/${resv.id}`)
.set('Cookie', authCookie(user.id))
.send({ create_budget_entry: { total_price: 300, category: 'Accommodation' } });
expect(res.status).toBe(200);
const budgetItem = testDb
.prepare('SELECT * FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(trip.id, resv.id) as any;
expect(budgetItem).toBeDefined();
expect(budgetItem.total_price).toBe(300);
});
it('RESV-013 — PUT with create_budget_entry updates existing linked budget item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Create reservation with budget entry via POST
const createRes = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({
title: 'Car Rental',
type: 'transport',
create_budget_entry: { total_price: 100, category: 'Transport' },
});
expect(createRes.status).toBe(201);
const resvId = createRes.body.reservation.id;
// Update with a new price — should update the existing budget item
const updateRes = await request(app)
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
.set('Cookie', authCookie(user.id))
.send({ create_budget_entry: { total_price: 150, category: 'Transport' } });
expect(updateRes.status).toBe(200);
const items = testDb
.prepare('SELECT * FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.all(trip.id, resvId) as any[];
expect(items).toHaveLength(1);
expect(items[0].total_price).toBe(150);
});
it('RESV-014 — PUT without create_budget_entry removes existing linked budget item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Create with budget entry
const createRes = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({
title: 'Taxi',
type: 'transport',
create_budget_entry: { total_price: 50, category: 'Transport' },
});
expect(createRes.status).toBe(201);
const resvId = createRes.body.reservation.id;
// Verify budget item exists
const before = testDb
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(trip.id, resvId);
expect(before).toBeDefined();
// Update without create_budget_entry — should delete the linked budget item
const updateRes = await request(app)
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Taxi Updated' });
expect(updateRes.status).toBe(200);
const after = testDb
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(trip.id, resvId);
expect(after).toBeUndefined();
});
});
describe('Reservation accommodation delete', () => {
it('RESV-009 — DELETE reservation linked to accommodation also removes the accommodation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day1 = createDay(testDb, trip.id, { date: '2025-07-01' });
const day2 = createDay(testDb, trip.id, { date: '2025-07-03' });
const place = createPlace(testDb, trip.id, { name: 'Hotel Belle' });
// Create a reservation via API with create_accommodation as an object
const createRes = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({
title: 'Hotel Belle Stay',
type: 'hotel',
day_id: day1.id,
create_accommodation: {
place_id: place.id,
start_day_id: day1.id,
end_day_id: day2.id,
},
});
expect(createRes.status).toBe(201);
const reservationId = createRes.body.reservation.id;
// Verify accommodation was created
const accom = testDb.prepare(
'SELECT id FROM day_accommodations WHERE trip_id = ?'
).get(trip.id) as any;
expect(accom).toBeDefined();
// Delete reservation — should also remove the accommodation
const delRes = await request(app)
.delete(`/api/trips/${trip.id}/reservations/${reservationId}`)
.set('Cookie', authCookie(user.id));
expect(delRes.status).toBe(200);
const accomAfter = testDb.prepare(
'SELECT id FROM day_accommodations WHERE id = ?'
).get(accom.id);
expect(accomAfter).toBeUndefined();
});
});
+25 -8
View File
@@ -10,6 +10,8 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import path from 'path';
import fs from 'fs';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -46,29 +48,35 @@ import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie, generateToken } from '../helpers/auth';
import { createUser, createTrip } from '../helpers/factories';
import { authCookie, authHeader, generateToken } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
const uploadsDir = path.join(__dirname, '../../uploads/files');
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
});
afterAll(() => {
fs.rmSync(uploadsDir, { recursive: true, force: true });
testDb.close();
});
describe('Authentication security', () => {
it('SEC-007 — JWT in Authorization Bearer header authenticates user', async () => {
it('SEC-007 — invalid JWT in Authorization Bearer header is rejected', async () => {
const { user } = createUser(testDb);
const token = generateToken(user.id);
@@ -162,12 +170,21 @@ describe('Request body size limit', () => {
describe('File download path traversal', () => {
it('SEC-005 — path traversal in file download is blocked', async () => {
const { user } = createUser(testDb);
const trip = { id: 1 };
const trip = createTrip(testDb, user.id);
const upload = await request(app)
.post(`/api/trips/${trip.id}/files`)
.set('Cookie', authCookie(user.id))
.attach('file', FIXTURE_IMG);
expect(upload.status).toBe(201);
const fileId = upload.body.file.id;
testDb.prepare('UPDATE trip_files SET filename = ? WHERE id = ?').run('../../etc/passwd', fileId);
const res = await request(app)
.get(`/api/trips/${trip.id}/files/1/download`)
.set('Authorization', `Bearer ${generateToken(user.id)}`);
// Trip 1 does not exist after resetTestDb → 404 before any file path is evaluated
expect(res.status).toBe(404);
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
.set(authHeader(user.id));
// resolveFilePath strips traversal via path.basename; normalized file does not exist in uploads
expect(res.status).not.toBe(200);
});
});
+189
View File
@@ -0,0 +1,189 @@
/**
* Settings integration tests SET-001 through SET-008.
* Covers GET /api/settings, PUT /api/settings, POST /api/settings/bulk.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
});
afterAll(() => {
testDb.close();
});
describe('Settings', () => {
it('SET-001: GET /api/settings returns empty object for new user', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/settings')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.settings).toBeDefined();
expect(typeof res.body.settings).toBe('object');
// New user has no custom settings
expect(Object.keys(res.body.settings)).toHaveLength(0);
});
it('SET-002: PUT /api/settings sets a key/value pair', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put('/api/settings')
.set('Cookie', authCookie(user.id))
.send({ key: 'theme', value: 'dark' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.key).toBe('theme');
expect(res.body.value).toBe('dark');
});
it('SET-003: PUT /api/settings updates an existing key', async () => {
const { user } = createUser(testDb);
await request(app)
.put('/api/settings')
.set('Cookie', authCookie(user.id))
.send({ key: 'theme', value: 'dark' });
const res = await request(app)
.put('/api/settings')
.set('Cookie', authCookie(user.id))
.send({ key: 'theme', value: 'light' });
expect(res.status).toBe(200);
expect(res.body.value).toBe('light');
// Verify the GET reflects the updated value
const getRes = await request(app)
.get('/api/settings')
.set('Cookie', authCookie(user.id));
expect(getRes.body.settings.theme).toBe('light');
});
it('SET-004: POST /api/settings/bulk upserts multiple settings', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/settings/bulk')
.set('Cookie', authCookie(user.id))
.send({ settings: { theme: 'dark', language: 'en', compact_mode: 'true' } });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.updated).toBeGreaterThanOrEqual(3);
});
it('SET-005: GET /api/settings reflects previously upserted values', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/settings/bulk')
.set('Cookie', authCookie(user.id))
.send({ settings: { theme: 'dark', language: 'fr' } });
const res = await request(app)
.get('/api/settings')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.settings.theme).toBe('dark');
expect(res.body.settings.language).toBe('fr');
});
it('SET-006: GET /api/settings without auth returns 401', async () => {
const res = await request(app).get('/api/settings');
expect(res.status).toBe(401);
});
it('SET-007: PUT /api/settings without key returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put('/api/settings')
.set('Cookie', authCookie(user.id))
.send({ value: 'dark' });
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('SET-008: PUT /api/settings with masked value is ignored (no-op)', async () => {
const { user } = createUser(testDb);
// First set a real value
await request(app)
.put('/api/settings')
.set('Cookie', authCookie(user.id))
.send({ key: 'webhook_url', value: 'https://example.com/hook' });
// Then try to "save" the masked placeholder
const res = await request(app)
.put('/api/settings')
.set('Cookie', authCookie(user.id))
.send({ key: 'webhook_url', value: '••••••••' });
expect(res.status).toBe(200);
expect(res.body.unchanged).toBe(true);
});
it('SET-009: POST /api/settings/bulk without settings object returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/settings/bulk')
.set('Cookie', authCookie(user.id))
.send({ settings: null });
expect(res.status).toBe(400);
});
it('SET-010: settings are user-scoped (user A cannot see user B settings)', async () => {
const { user: userA } = createUser(testDb);
const { user: userB } = createUser(testDb);
await request(app)
.put('/api/settings')
.set('Cookie', authCookie(userA.id))
.send({ key: 'secret_setting', value: 'user_a_secret' });
const res = await request(app)
.get('/api/settings')
.set('Cookie', authCookie(userB.id));
expect(res.status).toBe(200);
expect(res.body.settings.secret_setting).toBeUndefined();
});
});
+81 -1
View File
@@ -41,7 +41,7 @@ import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser, createTrip, addTripMember } from '../helpers/factories';
import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createDayNote } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
@@ -205,3 +205,83 @@ describe('Shared trip access', () => {
expect(res.status).toBe(404);
});
});
describe('Shared trip — day assignments and notes', () => {
it('SHARE-010 — shared trip with days and assignments includes place data in assignments', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Rome Trip' });
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
const place = createPlace(testDb, trip.id, { name: 'Colosseum', lat: 41.89, lng: 12.49 });
createDayAssignment(testDb, day.id, place.id, { notes: 'Amazing site' });
const create = await request(app)
.post(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id))
.send({});
const token = create.body.token;
const res = await request(app).get(`/api/shared/${token}`);
expect(res.status).toBe(200);
expect(res.body.days).toHaveLength(1);
const dayAssignments = res.body.assignments[day.id];
expect(Array.isArray(dayAssignments)).toBe(true);
expect(dayAssignments).toHaveLength(1);
expect(dayAssignments[0].place.name).toBe('Colosseum');
expect(dayAssignments[0].place.lat).toBe(41.89);
});
it('SHARE-011 — shared trip with day notes includes notes in response', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Notes Trip' });
const day = createDay(testDb, trip.id, { date: '2025-07-01' });
createDayNote(testDb, day.id, trip.id, { text: 'Meet at the station' });
const create = await request(app)
.post(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id))
.send({});
const token = create.body.token;
const res = await request(app).get(`/api/shared/${token}`);
expect(res.status).toBe(200);
const dayNotes = res.body.dayNotes[day.id];
expect(Array.isArray(dayNotes)).toBe(true);
expect(dayNotes).toHaveLength(1);
expect(dayNotes[0].text).toBe('Meet at the station');
});
it('SHARE-012 — share_collab=true includes collab messages in response', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, ?, 0)').run(trip.id, user.id, 'Hello team!');
const create = await request(app)
.post(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id))
.send({ share_collab: true });
const token = create.body.token;
const res = await request(app).get(`/api/shared/${token}`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.collab)).toBe(true);
expect(res.body.collab).toHaveLength(1);
expect(res.body.collab[0].text).toBe('Hello team!');
});
it('SHARE-013 — assignments empty when days have no assignments', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createDay(testDb, trip.id, { date: '2025-08-01' });
const create = await request(app)
.post(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id))
.send({});
const token = create.body.token;
const res = await request(app).get(`/api/shared/${token}`);
expect(res.status).toBe(200);
expect(res.body.days).toHaveLength(1);
expect(res.body.assignments).toEqual({});
});
});
+191
View File
@@ -0,0 +1,191 @@
/**
* Tags integration tests TAG-001 through TAG-010.
* Covers GET/POST/PUT/DELETE /api/tags.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
});
afterAll(() => {
testDb.close();
});
describe('Tags', () => {
it('TAG-001: GET /api/tags returns empty array for new user', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/tags')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.tags).toEqual([]);
});
it('TAG-002: POST /api/tags creates a tag with default color', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/tags')
.set('Cookie', authCookie(user.id))
.send({ name: 'Must See' });
expect(res.status).toBe(201);
expect(res.body.tag).toMatchObject({ name: 'Must See', user_id: user.id });
expect(res.body.tag.id).toBeDefined();
expect(res.body.tag.color).toBe('#10b981'); // default color
});
it('TAG-003: POST /api/tags creates a tag with a custom color', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/tags')
.set('Cookie', authCookie(user.id))
.send({ name: 'Foodie', color: '#f59e0b' });
expect(res.status).toBe(201);
expect(res.body.tag.color).toBe('#f59e0b');
});
it('TAG-004: POST /api/tags without name returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/tags')
.set('Cookie', authCookie(user.id))
.send({ color: '#ff0000' });
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('TAG-005: PUT /api/tags/:id updates tag name and color', async () => {
const { user } = createUser(testDb);
const createRes = await request(app)
.post('/api/tags')
.set('Cookie', authCookie(user.id))
.send({ name: 'Old Name', color: '#aaaaaa' });
const tagId = createRes.body.tag.id;
const res = await request(app)
.put(`/api/tags/${tagId}`)
.set('Cookie', authCookie(user.id))
.send({ name: 'New Name', color: '#bbbbbb' });
expect(res.status).toBe(200);
expect(res.body.tag.name).toBe('New Name');
expect(res.body.tag.color).toBe('#bbbbbb');
});
it('TAG-006: PUT /api/tags/:id - tag belonging to another user returns 404', async () => {
const { user: userA } = createUser(testDb);
const { user: userB } = createUser(testDb);
const createRes = await request(app)
.post('/api/tags')
.set('Cookie', authCookie(userA.id))
.send({ name: 'User A Tag' });
const tagId = createRes.body.tag.id;
// User B tries to update User A's tag
const res = await request(app)
.put(`/api/tags/${tagId}`)
.set('Cookie', authCookie(userB.id))
.send({ name: 'Hijacked' });
expect(res.status).toBe(404);
});
it('TAG-007: DELETE /api/tags/:id removes the tag', async () => {
const { user } = createUser(testDb);
const createRes = await request(app)
.post('/api/tags')
.set('Cookie', authCookie(user.id))
.send({ name: 'To Delete' });
const tagId = createRes.body.tag.id;
const res = await request(app)
.delete(`/api/tags/${tagId}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Verify gone
const listRes = await request(app)
.get('/api/tags')
.set('Cookie', authCookie(user.id));
expect(listRes.body.tags).toHaveLength(0);
});
it('TAG-008: DELETE /api/tags/:id - tag belonging to another user returns 404', async () => {
const { user: userA } = createUser(testDb);
const { user: userB } = createUser(testDb);
const createRes = await request(app)
.post('/api/tags')
.set('Cookie', authCookie(userA.id))
.send({ name: 'User A Tag' });
const tagId = createRes.body.tag.id;
const res = await request(app)
.delete(`/api/tags/${tagId}`)
.set('Cookie', authCookie(userB.id));
expect(res.status).toBe(404);
});
it('TAG-009: Tags are user-scoped — user A cannot see user B tags', async () => {
const { user: userA } = createUser(testDb);
const { user: userB } = createUser(testDb);
await request(app)
.post('/api/tags')
.set('Cookie', authCookie(userA.id))
.send({ name: 'User A Private Tag' });
const res = await request(app)
.get('/api/tags')
.set('Cookie', authCookie(userB.id));
expect(res.status).toBe(200);
expect(res.body.tags).toHaveLength(0);
});
it('TAG-010: Unauthenticated request returns 401', async () => {
const res = await request(app).get('/api/tags');
expect(res.status).toBe(401);
});
});
+321
View File
@@ -0,0 +1,321 @@
/**
* Todo integration tests TODO-001 through TODO-012.
* Covers all endpoints at /api/trips/:tripId/todo.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser, createTrip, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { invalidatePermissionsCache } from '../../src/services/permissions';
const app: Application = createApp();
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
invalidatePermissionsCache();
});
afterAll(() => {
testDb.close();
});
describe('Todo items', () => {
it('TODO-001: GET /api/trips/:id/todo returns empty items for a new trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/todo`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.items).toEqual([]);
});
it('TODO-002: POST /api/trips/:id/todo creates a todo with title only', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/todo`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Book hotel' });
expect(res.status).toBe(201);
expect(res.body.item).toMatchObject({ name: 'Book hotel', checked: 0, trip_id: trip.id });
expect(res.body.item.id).toBeDefined();
});
it('TODO-003: POST /api/trips/:id/todo creates a todo with all optional fields', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/todo`)
.set('Cookie', authCookie(user.id))
.send({
name: 'Pack suitcase',
category: 'Preparation',
description: 'Pack everything for the trip',
priority: 2,
});
expect(res.status).toBe(201);
expect(res.body.item).toMatchObject({
name: 'Pack suitcase',
category: 'Preparation',
description: 'Pack everything for the trip',
priority: 2,
});
});
it('TODO-004: POST /api/trips/:id/todo - missing name returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/todo`)
.set('Cookie', authCookie(user.id))
.send({ category: 'Test' });
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('TODO-005: PUT /api/trips/:id/todo/:todoId toggles checked status', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const createRes = await request(app)
.post(`/api/trips/${trip.id}/todo`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Visit museum' });
const itemId = createRes.body.item.id;
// Toggle to checked
const res = await request(app)
.put(`/api/trips/${trip.id}/todo/${itemId}`)
.set('Cookie', authCookie(user.id))
.send({ checked: 1 });
expect(res.status).toBe(200);
expect(res.body.item.checked).toBe(1);
// Toggle back to unchecked
const res2 = await request(app)
.put(`/api/trips/${trip.id}/todo/${itemId}`)
.set('Cookie', authCookie(user.id))
.send({ checked: 0 });
expect(res2.status).toBe(200);
expect(res2.body.item.checked).toBe(0);
});
it('TODO-006: PUT /api/trips/:id/todo/:todoId updates category', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const createRes = await request(app)
.post(`/api/trips/${trip.id}/todo`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Buy souvenirs' });
const itemId = createRes.body.item.id;
const res = await request(app)
.put(`/api/trips/${trip.id}/todo/${itemId}`)
.set('Cookie', authCookie(user.id))
.send({ category: 'Shopping' });
expect(res.status).toBe(200);
expect(res.body.item.category).toBe('Shopping');
});
it('TODO-007: DELETE /api/trips/:id/todo/:todoId deletes a todo', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const createRes = await request(app)
.post(`/api/trips/${trip.id}/todo`)
.set('Cookie', authCookie(user.id))
.send({ name: 'To Delete' });
const itemId = createRes.body.item.id;
const res = await request(app)
.delete(`/api/trips/${trip.id}/todo/${itemId}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Verify gone from list
const listRes = await request(app)
.get(`/api/trips/${trip.id}/todo`)
.set('Cookie', authCookie(user.id));
expect(listRes.body.items).toHaveLength(0);
});
it('TODO-008: PUT /api/trips/:id/todo/reorder reorders items', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Create 3 items
const r1 = await request(app)
.post(`/api/trips/${trip.id}/todo`)
.set('Cookie', authCookie(user.id))
.send({ name: 'First' });
const r2 = await request(app)
.post(`/api/trips/${trip.id}/todo`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Second' });
const r3 = await request(app)
.post(`/api/trips/${trip.id}/todo`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Third' });
const id1 = r1.body.item.id;
const id2 = r2.body.item.id;
const id3 = r3.body.item.id;
// Reverse the order
const res = await request(app)
.put(`/api/trips/${trip.id}/todo/reorder`)
.set('Cookie', authCookie(user.id))
.send({ orderedIds: [id3, id2, id1] });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Verify the new order in the DB
const items = testDb.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(trip.id) as any[];
expect(items[0].id).toBe(id3);
expect(items[1].id).toBe(id2);
expect(items[2].id).toBe(id1);
});
it('TODO-009: Non-member accessing trip returns 404', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/todo`)
.set('Cookie', authCookie(stranger.id));
expect(res.status).toBe(404);
});
it('TODO-010: Trip member can read and create todos', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
// Member can read
const getRes = await request(app)
.get(`/api/trips/${trip.id}/todo`)
.set('Cookie', authCookie(member.id));
expect(getRes.status).toBe(200);
// Member can create
const postRes = await request(app)
.post(`/api/trips/${trip.id}/todo`)
.set('Cookie', authCookie(member.id))
.send({ name: 'Member task' });
expect(postRes.status).toBe(201);
});
it('TODO-011: PUT /api/trips/:id/todo/:todoId - non-existent item returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/todo/99999`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Ghost' });
expect(res.status).toBe(404);
});
it('TODO-012: GET /api/trips/:id/todo - unauthenticated returns 401', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app).get(`/api/trips/${trip.id}/todo`);
expect(res.status).toBe(401);
});
});
describe('Todo category assignees', () => {
it('TODO-013: GET /api/trips/:id/todo/category-assignees returns empty object for new trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/todo/category-assignees`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.assignees).toEqual({});
});
it('TODO-014: PUT /api/trips/:id/todo/category-assignees/:name sets assignees', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/todo/category-assignees/Shopping`)
.set('Cookie', authCookie(owner.id))
.send({ user_ids: [owner.id, member.id] });
expect(res.status).toBe(200);
expect(Array.isArray(res.body.assignees)).toBe(true);
expect(res.body.assignees).toHaveLength(2);
// Verify via GET
const getRes = await request(app)
.get(`/api/trips/${trip.id}/todo/category-assignees`)
.set('Cookie', authCookie(owner.id));
expect(getRes.body.assignees.Shopping).toBeDefined();
expect(getRes.body.assignees.Shopping).toHaveLength(2);
});
it('TODO-015: PUT category-assignees with empty array clears assignees', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
// Set assignees
await request(app)
.put(`/api/trips/${trip.id}/todo/category-assignees/Shopping`)
.set('Cookie', authCookie(owner.id))
.send({ user_ids: [owner.id] });
// Clear them
const res = await request(app)
.put(`/api/trips/${trip.id}/todo/category-assignees/Shopping`)
.set('Cookie', authCookie(owner.id))
.send({ user_ids: [] });
expect(res.status).toBe(200);
expect(res.body.assignees).toHaveLength(0);
});
});
+210 -12
View File
@@ -49,7 +49,7 @@ import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation } from '../helpers/factories';
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation, createTag, createDayAccommodation, createBudgetItem, createPackingItem, createDayNote } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { invalidatePermissionsCache } from '../../src/services/permissions';
@@ -291,17 +291,6 @@ describe('Get trip', () => {
expect(res.body.error).toMatch(/not found/i);
});
it('TRIP-016 — Non-member cannot access trip → 404', async () => {
const { user: owner } = createUser(testDb);
const { user: nonMember } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
const res = await request(app)
.get(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(nonMember.id));
expect(res.status).toBe(404);
});
it('TRIP-017 — Member can access trip → 200', async () => {
const { user: owner } = createUser(testDb);
@@ -694,3 +683,212 @@ describe('Trip members', () => {
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Copy trip (TRIP-023, TRIP-024)
// ─────────────────────────────────────────────────────────────────────────────
describe('Copy trip', () => {
it('TRIP-023 — POST /api/trips/:id/copy creates a duplicate trip with 201', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Original Trip', description: 'Desc' });
const res = await request(app)
.post(`/api/trips/${trip.id}/copy`)
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(201);
expect(res.body.trip).toBeDefined();
expect(res.body.trip.id).not.toBe(trip.id);
expect(res.body.trip.title).toBe('Original Trip');
});
it('TRIP-023 — copy accepts a custom title for the new trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Source' });
const res = await request(app)
.post(`/api/trips/${trip.id}/copy`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Custom Copy' });
expect(res.status).toBe(201);
expect(res.body.trip.title).toBe('Custom Copy');
});
it('TRIP-023 — copied trip belongs to the requesting user', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/copy`)
.set('Cookie', authCookie(member.id))
.send({});
expect(res.status).toBe(201);
const newTrip = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(res.body.trip.id) as any;
expect(newTrip.user_id).toBe(member.id);
});
it('TRIP-024 — non-member cannot copy a trip → 404', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/copy`)
.set('Cookie', authCookie(stranger.id))
.send({});
expect(res.status).toBe(404);
});
it('TRIP-024 — copy of non-existent trip returns 404', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/trips/999999/copy')
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// ICS export (TRIP-025)
// ─────────────────────────────────────────────────────────────────────────────
describe('ICS export', () => {
it('TRIP-025 — GET /api/trips/:id/export.ics returns text/calendar content', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' });
const res = await request(app)
.get(`/api/trips/${trip.id}/export.ics`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/calendar/);
expect(res.text).toContain('BEGIN:VCALENDAR');
expect(res.text).toContain('END:VCALENDAR');
});
it('TRIP-025 — non-member cannot export ICS → 404', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
const res = await request(app)
.get(`/api/trips/${trip.id}/export.ics`)
.set('Cookie', authCookie(stranger.id));
expect(res.status).toBe(404);
});
it('TRIP-025 — unauthenticated export returns 401', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const res = await request(app).get(`/api/trips/${trip.id}/export.ics`);
expect(res.status).toBe(401);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Copy trip with full data (covers loop bodies in the copy transaction)
// ─────────────────────────────────────────────────────────────────────────────
describe('Copy trip with data', () => {
it('TRIP-026 — copy preserves days, places, tags, assignments, accommodations, reservations, budget, packing, notes', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, {
title: 'Data-Rich Trip',
start_date: '2025-09-01',
end_date: '2025-09-03',
});
const days = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as any[];
expect(days.length).toBe(3);
// Place with a tag
const place = createPlace(testDb, trip.id, { name: 'Tower Bridge' });
const tag = createTag(testDb, user.id, { name: 'Landmark' });
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id);
// Day assignment
testDb.prepare(
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, 0, ?)'
).run(days[0].id, place.id, 'Visit in morning');
// Accommodation spanning days 0→1
createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
// Reservation on day 0
createReservation(testDb, trip.id, { title: 'Flight Out', type: 'flight', day_id: days[0].id });
// Budget item
createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 400 });
// Packing item
createPackingItem(testDb, trip.id, { name: 'Toothbrush' });
// Day note
createDayNote(testDb, days[0].id, trip.id, { text: 'Pack early!' });
const res = await request(app)
.post(`/api/trips/${trip.id}/copy`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Data-Rich Trip (Copy)' });
expect(res.status).toBe(201);
const newId = res.body.trip.id;
expect(newId).not.toBe(trip.id);
// Days copied
const newDays = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(newId) as any[];
expect(newDays).toHaveLength(3);
// Place copied
const newPlaces = testDb.prepare('SELECT * FROM places WHERE trip_id = ?').all(newId) as any[];
expect(newPlaces).toHaveLength(1);
expect(newPlaces[0].name).toBe('Tower Bridge');
// Place tag copied
const newTags = testDb.prepare(
'SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?'
).all(newId) as any[];
expect(newTags).toHaveLength(1);
// Assignment copied
const newAssignments = testDb.prepare(
'SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?'
).all(newId) as any[];
expect(newAssignments).toHaveLength(1);
// Accommodation copied
const newAccom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(newId) as any[];
expect(newAccom).toHaveLength(1);
// Reservation copied
const newResv = testDb.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(newId) as any[];
expect(newResv).toHaveLength(1);
// Budget copied
const newBudget = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(newId) as any[];
expect(newBudget).toHaveLength(1);
// Packing copied (checked reset to 0)
const newPacking = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(newId) as any[];
expect(newPacking).toHaveLength(1);
expect(newPacking[0].checked).toBe(0);
// Day note copied
const newNotes = testDb.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(newId) as any[];
expect(newNotes).toHaveLength(1);
expect(newNotes[0].text).toBe('Pack early!');
});
});
+227
View File
@@ -303,3 +303,230 @@ describe('Vacay dissolve plan', () => {
expect(res.status).toBe(200);
});
});
describe('Vacay holiday calendar CRUD', () => {
it('VACAY-026 — PUT /plan/holiday-calendars/:id updates an existing calendar', async () => {
const { user } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
// Create a calendar first
const createRes = await request(app)
.post('/api/addons/vacay/plan/holiday-calendars')
.set('Cookie', authCookie(user.id))
.send({ region: 'US', label: 'US Holidays' });
expect(createRes.status).toBe(200);
const calId = createRes.body.plan?.holiday_calendars?.at(-1)?.id
?? (testDb.prepare('SELECT id FROM vacay_holiday_calendars ORDER BY id DESC LIMIT 1').get() as any)?.id;
const res = await request(app)
.put(`/api/addons/vacay/plan/holiday-calendars/${calId}`)
.set('Cookie', authCookie(user.id))
.send({ label: 'Updated Label', color: '#ff0000' });
expect(res.status).toBe(200);
});
it('VACAY-027 — DELETE /plan/holiday-calendars/:id removes the calendar', async () => {
const { user } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
const createRes = await request(app)
.post('/api/addons/vacay/plan/holiday-calendars')
.set('Cookie', authCookie(user.id))
.send({ region: 'FR', label: 'French Holidays' });
expect(createRes.status).toBe(200);
const calId = (testDb.prepare('SELECT id FROM vacay_holiday_calendars ORDER BY id DESC LIMIT 1').get() as any)?.id;
const res = await request(app)
.delete(`/api/addons/vacay/plan/holiday-calendars/${calId}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('VACAY-027b — DELETE /plan/holiday-calendars/:id non-existent returns 404', async () => {
const { user } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
const res = await request(app)
.delete('/api/addons/vacay/plan/holiday-calendars/99999')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
});
describe('Vacay invite full flow', () => {
it('VACAY-028 — POST /invite/accept joins the invitee to the owner plan', async () => {
const { user: owner } = createUser(testDb);
const { user: invitee } = createUser(testDb);
// Owner creates plan
const planRes = await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
const planId = planRes.body.plan.id;
// Owner invites invitee
await request(app)
.post('/api/addons/vacay/invite')
.set('Cookie', authCookie(owner.id))
.send({ user_id: invitee.id });
// Invitee accepts
const res = await request(app)
.post('/api/addons/vacay/invite/accept')
.set('Cookie', authCookie(invitee.id))
.send({ plan_id: planId });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('VACAY-029 — POST /invite/decline removes the pending invite', async () => {
const { user: owner } = createUser(testDb);
const { user: invitee } = createUser(testDb);
const planRes = await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
const planId = planRes.body.plan.id;
await request(app)
.post('/api/addons/vacay/invite')
.set('Cookie', authCookie(owner.id))
.send({ user_id: invitee.id });
const res = await request(app)
.post('/api/addons/vacay/invite/decline')
.set('Cookie', authCookie(invitee.id))
.send({ plan_id: planId });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('VACAY-030 — POST /invite/cancel removes the pending invite from owner side', async () => {
const { user: owner } = createUser(testDb);
const { user: invitee } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
await request(app)
.post('/api/addons/vacay/invite')
.set('Cookie', authCookie(owner.id))
.send({ user_id: invitee.id });
const res = await request(app)
.post('/api/addons/vacay/invite/cancel')
.set('Cookie', authCookie(owner.id))
.send({ user_id: invitee.id });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
describe('Vacay company holidays', () => {
it('VACAY-032 — POST /entries/company-holiday toggles a company holiday', async () => {
const { user } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
const res = await request(app)
.post('/api/addons/vacay/entries/company-holiday')
.set('Cookie', authCookie(user.id))
.send({ date: '2025-12-25', note: 'Christmas' });
expect(res.status).toBe(200);
});
it('VACAY-033 — POST /entries/toggle with target_user_id not in plan returns 403', async () => {
const { user: owner } = createUser(testDb);
const { user: outsider } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(owner.id)).send({ year: 2025 });
const res = await request(app)
.post('/api/addons/vacay/entries/toggle')
.set('Cookie', authCookie(owner.id))
.send({ date: '2025-07-14', target_user_id: outsider.id });
expect(res.status).toBe(403);
});
});
describe('Vacay stats restrictions', () => {
it('VACAY-034 — PUT /stats/:year for user not in plan returns 403', async () => {
const { user: owner } = createUser(testDb);
const { user: outsider } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(owner.id)).send({ year: 2025 });
const res = await request(app)
.put('/api/addons/vacay/stats/2025')
.set('Cookie', authCookie(owner.id))
.send({ vacation_days: 25, target_user_id: outsider.id });
expect(res.status).toBe(403);
});
});
describe('Vacay holidays error path', () => {
it('VACAY-035 — GET /holidays/:year/:country returns 502 when external API fetch fails', async () => {
const { user } = createUser(testDb);
// Use an unusual country/year to avoid cache hits from other tests
vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
const res = await request(app)
.get('/api/addons/vacay/holidays/2099/ZZ')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(502);
});
});
describe('Vacay color restriction', () => {
it('VACAY-036 — PUT /color with target_user_id not in plan returns 403', async () => {
const { user: owner } = createUser(testDb);
const { user: outsider } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
const res = await request(app)
.put('/api/addons/vacay/color')
.set('Cookie', authCookie(owner.id))
.send({ color: '#ff0000', target_user_id: outsider.id });
expect(res.status).toBe(403);
});
});
describe('Vacay holidays success path', () => {
it('VACAY-037 — GET /holidays/:year/:country returns data when fetch succeeds', async () => {
const { user } = createUser(testDb);
// Use unique year/country to avoid cache from other tests
vi.mocked(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([{ date: '2025-05-01', name: 'Labour Day', countryCode: 'AT' }]),
});
const res = await request(app)
.get('/api/addons/vacay/holidays/2025/AT')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
});
});
describe('Vacay toggle entry for plan member', () => {
it('VACAY-038 — POST /entries/toggle with target_user_id in plan toggles their entry', async () => {
const { user: owner } = createUser(testDb);
const { user: invitee } = createUser(testDb);
const planRes = await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
const planId = planRes.body.plan.id;
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(owner.id)).send({ year: 2025 });
// Invite and accept so invitee is in the plan
await request(app)
.post('/api/addons/vacay/invite')
.set('Cookie', authCookie(owner.id))
.send({ user_id: invitee.id });
await request(app)
.post('/api/addons/vacay/invite/accept')
.set('Cookie', authCookie(invitee.id))
.send({ plan_id: planId });
// Owner toggles an entry for the invitee (who is now in the plan)
const res = await request(app)
.post('/api/addons/vacay/entries/toggle')
.set('Cookie', authCookie(owner.id))
.send({ date: '2025-06-10', target_user_id: invitee.id });
expect(res.status).toBe(200);
});
});
+106
View File
@@ -153,4 +153,110 @@ describe('Weather with mocked API', () => {
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('temp');
});
it('WEATHER-007 — GET /weather returns 500 on non-ok API response (ApiError path)', async () => {
const { user } = createUser(testDb);
// Use unique coords to avoid cache from previous tests
vi.mocked(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 503,
json: () => Promise.resolve({ error: true, reason: 'Service unavailable' }),
});
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 3);
const dateStr = futureDate.toISOString().slice(0, 10);
const res = await request(app)
.get(`/api/weather?lat=55.0&lng=25.0&date=${dateStr}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(503);
expect(res.body).toHaveProperty('error');
});
it('WEATHER-008 — GET /weather returns 500 on network error (generic error path)', async () => {
const { user } = createUser(testDb);
vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 4);
const dateStr = futureDate.toISOString().slice(0, 10);
const res = await request(app)
.get(`/api/weather?lat=56.0&lng=26.0&date=${dateStr}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(500);
expect(res.body).toHaveProperty('error');
});
it('WEATHER-009 — GET /weather/detailed returns detailed weather data', async () => {
const { user } = createUser(testDb);
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 2);
const dateStr = futureDate.toISOString().slice(0, 10);
// Override mock with full detailed forecast response
vi.mocked(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
daily: {
time: [dateStr],
temperature_2m_max: [24],
temperature_2m_min: [16],
weathercode: [1],
precipitation_sum: [0],
windspeed_10m_max: [12],
sunrise: [`${dateStr}T06:00`],
sunset: [`${dateStr}T21:00`],
precipitation_probability_max: [10],
},
hourly: {
time: [`${dateStr}T12:00`],
temperature_2m: [20],
precipitation_probability: [5],
precipitation: [0],
weathercode: [1],
windspeed_10m: [10],
relativehumidity_2m: [55],
},
}),
});
const res = await request(app)
.get(`/api/weather/detailed?lat=50.0&lng=10.0&date=${dateStr}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('temp');
expect(res.body.type).toBe('forecast');
});
it('WEATHER-010 — GET /weather/detailed returns error status on ApiError', async () => {
const { user } = createUser(testDb);
vi.mocked(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 502,
json: () => Promise.resolve({ error: true, reason: 'Bad Gateway' }),
});
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 6);
const dateStr = futureDate.toISOString().slice(0, 10);
const res = await request(app)
.get(`/api/weather/detailed?lat=57.0&lng=27.0&date=${dateStr}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(502);
expect(res.body).toHaveProperty('error');
});
it('WEATHER-011 — GET /weather/detailed returns 500 on network error', async () => {
const { user } = createUser(testDb);
vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 7);
const dateStr = futureDate.toISOString().slice(0, 10);
const res = await request(app)
.get(`/api/weather/detailed?lat=58.0&lng=28.0&date=${dateStr}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(500);
expect(res.body).toHaveProperty('error');
});
});
@@ -0,0 +1,244 @@
/**
* Unit tests for MCP extra assignment/reservation tools:
* move_assignment, get_assignment_participants, set_assignment_participants, reorder_reservations.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAssignment, createReservation } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// move_assignment
// ---------------------------------------------------------------------------
describe('Tool: move_assignment', () => {
it('moves assignment to a different day and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day1 = createDay(testDb, trip.id);
const day2 = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day1.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'move_assignment',
arguments: { tripId: trip.id, assignmentId: assignment.id, newDayId: day2.id, oldDayId: day1.id, orderIndex: 0 },
});
const data = parseToolResult(result) as any;
expect(data.assignment).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:moved', expect.any(Object));
// Verify the assignment was moved
const updated = testDb.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(assignment.id) as any;
expect(updated.day_id).toBe(day2.id);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'move_assignment',
arguments: { tripId: trip.id, assignmentId: 1, newDayId: day.id, oldDayId: day.id },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'move_assignment',
arguments: { tripId: trip.id, assignmentId: 1, newDayId: day.id, oldDayId: day.id },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// get_assignment_participants
// ---------------------------------------------------------------------------
describe('Tool: get_assignment_participants', () => {
it('returns empty participants array initially', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_assignment_participants',
arguments: { tripId: trip.id, assignmentId: assignment.id },
});
const data = parseToolResult(result) as any;
expect(Array.isArray(data.participants)).toBe(true);
expect(data.participants).toHaveLength(0);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_assignment_participants', arguments: { tripId: trip.id, assignmentId: 1 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// set_assignment_participants
// ---------------------------------------------------------------------------
describe('Tool: set_assignment_participants', () => {
it('sets participants and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_assignment_participants',
arguments: { tripId: trip.id, assignmentId: assignment.id, userIds: [user.id] },
});
const data = parseToolResult(result) as any;
expect(Array.isArray(data.participants)).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:participants', expect.any(Object));
});
});
it('empty array clears participants', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
// First set
testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(assignment.id, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_assignment_participants',
arguments: { tripId: trip.id, assignmentId: assignment.id, userIds: [] },
});
const data = parseToolResult(result) as any;
expect(data.participants).toEqual([]);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_assignment_participants',
arguments: { tripId: trip.id, assignmentId: 1, userIds: [] },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// reorder_reservations
// ---------------------------------------------------------------------------
describe('Tool: reorder_reservations', () => {
it('returns success and broadcasts reservation:positions', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res1 = createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' });
const res2 = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'reorder_reservations',
arguments: {
tripId: trip.id,
positions: [
{ id: res1.id, day_plan_position: 1 },
{ id: res2.id, day_plan_position: 0 },
],
},
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:positions', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'reorder_reservations',
arguments: { tripId: trip.id, positions: [{ id: 1, day_plan_position: 0 }] },
});
expect(result.isError).toBe(true);
});
});
});
@@ -0,0 +1,313 @@
/**
* Unit tests for MCP atlas expanded tools (atlas addon-gated):
* get_atlas_stats, list_visited_regions, mark_region_visited, unmark_region_visited,
* get_country_atlas_places, update_bucket_list_item.
* Also covers resources trek://atlas/stats and trek://atlas/regions.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
vi.mock('../../../src/services/adminService', () => ({
isAddonEnabled: vi.fn().mockReturnValue(true),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withTools: false, withResources: true });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// get_atlas_stats
// ---------------------------------------------------------------------------
describe('Tool: get_atlas_stats', () => {
it('returns stats object without error for empty data', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_atlas_stats', arguments: {} });
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as any;
expect(data.stats).toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
// list_visited_regions
// ---------------------------------------------------------------------------
describe('Tool: list_visited_regions', () => {
it('returns empty array initially', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.regions).toEqual([]);
});
});
it('returns regions after they have been inserted', async () => {
const { user } = createUser(testDb);
testDb.prepare(
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
).run(user.id, 'FR-75', 'Paris', 'FR');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.regions).toHaveLength(1);
expect(data.regions[0].region_code).toBe('FR-75');
});
});
});
// ---------------------------------------------------------------------------
// mark_region_visited
// ---------------------------------------------------------------------------
describe('Tool: mark_region_visited', () => {
it('inserts region and returns region object', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_region_visited',
arguments: { regionCode: 'US-CA', regionName: 'California', countryCode: 'US' },
});
const data = parseToolResult(result) as any;
expect(data.region).toBeDefined();
expect(data.region.region_code).toBe('US-CA');
expect(data.region.region_name).toBe('California');
expect(data.region.country_code).toBe('US');
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'US-CA');
expect(row).toBeTruthy();
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_region_visited',
arguments: { regionCode: 'DE-BY', regionName: 'Bavaria', countryCode: 'DE' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// unmark_region_visited
// ---------------------------------------------------------------------------
describe('Tool: unmark_region_visited', () => {
it('removes region and returns success', async () => {
const { user } = createUser(testDb);
testDb.prepare(
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
).run(user.id, 'IT-LO', 'Lombardy', 'IT');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'unmark_region_visited',
arguments: { regionCode: 'IT-LO' },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'IT-LO');
expect(row).toBeUndefined();
});
});
it('succeeds even when region was not marked (no-op)', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'unmark_region_visited',
arguments: { regionCode: 'XX-YY' },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// get_country_atlas_places
// ---------------------------------------------------------------------------
describe('Tool: get_country_atlas_places', () => {
it('returns empty places array for a new user', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_country_atlas_places',
arguments: { countryCode: 'JP' },
});
const data = parseToolResult(result) as any;
expect(data.places).toBeDefined();
expect(Array.isArray(data.places)).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// update_bucket_list_item
// ---------------------------------------------------------------------------
describe('Tool: update_bucket_list_item', () => {
it('updates notes and returns item', async () => {
const { user } = createUser(testDb);
const r = testDb.prepare(
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
).run(user.id, 'Visit Tokyo');
const itemId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_bucket_list_item',
arguments: { itemId, notes: 'Cherry blossom season preferred' },
});
const data = parseToolResult(result) as any;
expect(data.item).toBeDefined();
expect(data.item.notes).toBe('Cherry blossom season preferred');
});
});
it('updates name of existing item', async () => {
const { user } = createUser(testDb);
const r = testDb.prepare(
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
).run(user.id, 'Old Name');
const itemId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_bucket_list_item',
arguments: { itemId, name: 'New Name' },
});
const data = parseToolResult(result) as any;
expect(data.item.name).toBe('New Name');
});
});
it('returns isError for non-existent item', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_bucket_list_item',
arguments: { itemId: 99999, notes: 'Will not work' },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const r = testDb.prepare(
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
).run(user.id, 'Bucket Item');
const itemId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_bucket_list_item',
arguments: { itemId, notes: 'blocked' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// Resource: trek://atlas/stats
// ---------------------------------------------------------------------------
describe('Resource: trek://atlas/stats', () => {
it('returns stats object', async () => {
const { user } = createUser(testDb);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://atlas/stats' });
const data = parseResourceResult(result) as any;
expect(data).toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
// Resource: trek://atlas/regions
// ---------------------------------------------------------------------------
describe('Resource: trek://atlas/regions', () => {
it('returns regions array', async () => {
const { user } = createUser(testDb);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://atlas/regions' });
const data = parseResourceResult(result) as any;
expect(Array.isArray(data)).toBe(true);
});
});
it('returns inserted regions', async () => {
const { user } = createUser(testDb);
testDb.prepare(
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
).run(user.id, 'ES-CT', 'Catalonia', 'ES');
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://atlas/regions' });
const data = parseResourceResult(result) as any;
expect(data).toHaveLength(1);
expect(data[0].region_code).toBe('ES-CT');
});
});
});
@@ -0,0 +1,213 @@
/**
* Unit tests for MCP budget advanced tools:
* set_budget_item_members, toggle_budget_member_paid.
* Resources: trek://trips/{tripId}/budget/per-person, trek://trips/{tripId}/budget/settlement.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: true });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// set_budget_item_members
// ---------------------------------------------------------------------------
describe('Tool: set_budget_item_members', () => {
it('sets members and broadcasts budget:members-updated', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 500 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_budget_item_members',
arguments: { tripId: trip.id, itemId: item.id, userIds: [user.id] },
});
const data = parseToolResult(result) as any;
expect(data.item).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:members-updated', expect.any(Object));
});
});
it('empty array clears members', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id) VALUES (?, ?)').run(item.id, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_budget_item_members',
arguments: { tripId: trip.id, itemId: item.id, userIds: [] },
});
const data = parseToolResult(result) as any;
expect(data.item).toBeDefined();
const remaining = testDb.prepare('SELECT count(*) as cnt FROM budget_item_members WHERE budget_item_id = ?').get(item.id) as any;
expect(remaining.cnt).toBe(0);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_budget_item_members',
arguments: { tripId: trip.id, itemId: item.id, userIds: [] },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_budget_item_members',
arguments: { tripId: trip.id, itemId: item.id, userIds: [] },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// toggle_budget_member_paid
// ---------------------------------------------------------------------------
describe('Tool: toggle_budget_member_paid', () => {
it('flips paid flag and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { total_price: 200 });
// Add member first
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)').run(item.id, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'toggle_budget_member_paid',
arguments: { tripId: trip.id, itemId: item.id, memberId: user.id, paid: true },
});
const data = parseToolResult(result) as any;
expect(data.member).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:member-paid-updated', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'toggle_budget_member_paid',
arguments: { tripId: trip.id, itemId: item.id, memberId: user.id, paid: true },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// Per-person resource
// ---------------------------------------------------------------------------
describe('Resource: trek://trips/{tripId}/budget/per-person', () => {
it('returns array for trip with no items', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/per-person` });
const data = JSON.parse(result.contents[0].text as string);
expect(Array.isArray(data)).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/per-person` });
const data = JSON.parse(result.contents[0].text as string);
expect(data.error).toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
// Settlement resource
// ---------------------------------------------------------------------------
describe('Resource: trek://trips/{tripId}/budget/settlement', () => {
it('returns settlement object for trip with no items', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/settlement` });
const data = JSON.parse(result.contents[0].text as string);
expect(data).toBeDefined();
expect(Array.isArray(data.balances) || Array.isArray(data)).toBe(true);
});
});
});
@@ -0,0 +1,500 @@
/**
* Unit tests for MCP collab polls and chat tools (collab addon-gated):
* list_collab_polls, create_collab_poll, vote_collab_poll, close_collab_poll,
* delete_collab_poll, list_collab_messages, send_collab_message,
* delete_collab_message, react_collab_message.
* Resources: trek://trips/{tripId}/collab/polls, trek://trips/{tripId}/collab/messages.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
vi.mock('../../../src/services/adminService', () => ({
isAddonEnabled: vi.fn().mockReturnValue(true),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: true });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// list_collab_polls
// ---------------------------------------------------------------------------
describe('Tool: list_collab_polls', () => {
it('returns empty array initially', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'list_collab_polls',
arguments: { tripId: trip.id },
});
const data = parseToolResult(result) as any;
expect(Array.isArray(data.polls)).toBe(true);
expect(data.polls).toHaveLength(0);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_collab_polls', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// create_collab_poll
// ---------------------------------------------------------------------------
describe('Tool: create_collab_poll', () => {
it('inserts poll with votes structure and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_collab_poll',
arguments: {
tripId: trip.id,
question: 'Where should we eat?',
options: ['Pizza', 'Sushi', 'Tacos'],
},
});
const data = parseToolResult(result) as any;
expect(data.poll).toBeDefined();
expect(data.poll.question).toBe('Where should we eat?');
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:created', expect.any(Object));
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_collab_poll',
arguments: { tripId: trip.id, question: 'Q?', options: ['A', 'B'] },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_collab_poll',
arguments: { tripId: trip.id, question: 'Q?', options: ['A', 'B'] },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// vote_collab_poll
// ---------------------------------------------------------------------------
describe('Tool: vote_collab_poll', () => {
it('records vote and broadcasts collab:poll:voted', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Create a poll directly in the DB
const pollId = (testDb.prepare(
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'Best city?', JSON.stringify(['Paris', 'Rome'])) as any).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'vote_collab_poll',
arguments: { tripId: trip.id, pollId: Number(pollId), optionIndex: 0 },
});
const data = parseToolResult(result) as any;
expect(data.poll).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:voted', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'vote_collab_poll',
arguments: { tripId: trip.id, pollId: 1, optionIndex: 0 },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// close_collab_poll
// ---------------------------------------------------------------------------
describe('Tool: close_collab_poll', () => {
it('sets closed flag and broadcasts collab:poll:closed', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const pollId = (testDb.prepare(
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'Vote now?', JSON.stringify(['Yes', 'No'])) as any).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'close_collab_poll',
arguments: { tripId: trip.id, pollId: Number(pollId) },
});
const data = parseToolResult(result) as any;
expect(data.poll).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:closed', expect.any(Object));
});
});
it('returns error for non-existent poll', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'close_collab_poll',
arguments: { tripId: trip.id, pollId: 99999 },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'close_collab_poll', arguments: { tripId: trip.id, pollId: 1 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_collab_poll
// ---------------------------------------------------------------------------
describe('Tool: delete_collab_poll', () => {
it('removes poll and broadcasts collab:poll:deleted', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const pollId = (testDb.prepare(
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'Delete me?', JSON.stringify(['Yes', 'No'])) as any).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_collab_poll',
arguments: { tripId: trip.id, pollId: Number(pollId) },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:deleted', expect.objectContaining({ pollId: Number(pollId) }));
expect(testDb.prepare('SELECT id FROM collab_polls WHERE id = ?').get(Number(pollId))).toBeUndefined();
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_collab_poll', arguments: { tripId: trip.id, pollId: 1 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// list_collab_messages
// ---------------------------------------------------------------------------
describe('Tool: list_collab_messages', () => {
it('returns empty array initially', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'list_collab_messages',
arguments: { tripId: trip.id },
});
const data = parseToolResult(result) as any;
expect(Array.isArray(data.messages)).toBe(true);
expect(data.messages).toHaveLength(0);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_collab_messages', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// send_collab_message
// ---------------------------------------------------------------------------
describe('Tool: send_collab_message', () => {
it('inserts message and broadcasts collab:message:created', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'send_collab_message',
arguments: { tripId: trip.id, text: 'Hello team!' },
});
const data = parseToolResult(result) as any;
expect(data.message).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:message:created', expect.any(Object));
});
});
it('sends message with replyTo when parent exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msgId = (testDb.prepare(
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'Original message') as any).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'send_collab_message',
arguments: { tripId: trip.id, text: 'Reply here', replyTo: Number(msgId) },
});
const data = parseToolResult(result) as any;
expect(data.message).toBeDefined();
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'send_collab_message',
arguments: { tripId: trip.id, text: 'Hello!' },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'send_collab_message', arguments: { tripId: trip.id, text: 'Hi' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_collab_message
// ---------------------------------------------------------------------------
describe('Tool: delete_collab_message', () => {
it('soft-deletes message and broadcasts collab:message:deleted', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msgId = (testDb.prepare(
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'To be deleted') as any).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_collab_message',
arguments: { tripId: trip.id, messageId: Number(msgId) },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:message:deleted', expect.any(Object));
});
});
it('returns error when message belongs to different user', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Add other as trip member
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, other.id);
const msgId = (testDb.prepare(
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'Owner message') as any).lastInsertRowid;
await withHarness(other.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_collab_message',
arguments: { tripId: trip.id, messageId: Number(msgId) },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_collab_message', arguments: { tripId: trip.id, messageId: 1 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// react_collab_message
// ---------------------------------------------------------------------------
describe('Tool: react_collab_message', () => {
it('toggles reaction and broadcasts collab:message:reacted', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msgId = (testDb.prepare(
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'React to me') as any).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'react_collab_message',
arguments: { tripId: trip.id, messageId: Number(msgId), emoji: '👍' },
});
const data = parseToolResult(result) as any;
expect(data.reactions).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:message:reacted', expect.any(Object));
});
});
it('returns error for non-existent message', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'react_collab_message',
arguments: { tripId: trip.id, messageId: 99999, emoji: '👍' },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'react_collab_message', arguments: { tripId: trip.id, messageId: 1, emoji: '👍' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
describe('Resource: trek://trips/{tripId}/collab/polls', () => {
it('returns polls list', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab/polls` });
const data = parseResourceResult(result) as any;
expect(Array.isArray(data)).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab/polls` });
const data = parseResourceResult(result) as any;
expect(data.error).toBeDefined();
});
});
});
describe('Resource: trek://trips/{tripId}/collab/messages', () => {
it('returns messages list', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab/messages` });
const data = parseResourceResult(result) as any;
expect(Array.isArray(data)).toBe(true);
});
});
});
@@ -0,0 +1,294 @@
/**
* Unit tests for MCP day and accommodation tools:
* create_day, delete_day,
* create_accommodation, update_accommodation, delete_accommodation.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAccommodation } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// create_day
// ---------------------------------------------------------------------------
describe('Tool: create_day', () => {
it('creates a day with a date', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_day',
arguments: { tripId: trip.id, date: '2025-06-15', notes: 'Arrival day' },
});
const data = parseToolResult(result) as any;
expect(data.day).toBeDefined();
expect(data.day.date).toBe('2025-06-15');
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:created', expect.any(Object));
});
});
it('creates a dateless day', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_day',
arguments: { tripId: trip.id },
});
const data = parseToolResult(result) as any;
expect(data.day).toBeDefined();
expect(data.day.date).toBeNull();
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_day', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_day', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_day
// ---------------------------------------------------------------------------
describe('Tool: delete_day', () => {
it('deletes a day and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_day',
arguments: { tripId: trip.id, dayId: day.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:deleted', { id: day.id });
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeUndefined();
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: day.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// create_accommodation
// ---------------------------------------------------------------------------
describe('Tool: create_accommodation', () => {
it('creates an accommodation and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Hotel du Louvre' });
const day1 = createDay(testDb, trip.id, { date: '2025-06-15' });
const day2 = createDay(testDb, trip.id, { date: '2025-06-17' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_accommodation',
arguments: {
tripId: trip.id,
place_id: place.id,
start_day_id: day1.id,
end_day_id: day2.id,
check_in: '15:00',
check_out: '11:00',
confirmation: 'CONF123',
},
});
const data = parseToolResult(result) as any;
expect(data.accommodation).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const place = createPlace(testDb, trip.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_accommodation',
arguments: { tripId: trip.id, place_id: place.id, start_day_id: day.id, end_day_id: day.id },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_accommodation',
arguments: { tripId: trip.id, place_id: place.id, start_day_id: day.id, end_day_id: day.id },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// update_accommodation
// ---------------------------------------------------------------------------
describe('Tool: update_accommodation', () => {
it('updates accommodation fields and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
const day1 = createDay(testDb, trip.id);
const day2 = createDay(testDb, trip.id);
const acc = createDayAccommodation(testDb, trip.id, place.id, day1.id, day2.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_accommodation',
arguments: { tripId: trip.id, accommodationId: acc.id, confirmation: 'NEW-CONF', check_in: '14:00' },
});
const data = parseToolResult(result) as any;
expect(data.accommodation).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:updated', expect.any(Object));
});
});
it('returns error for non-existent accommodation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_accommodation',
arguments: { tripId: trip.id, accommodationId: 99999, confirmation: 'X' },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_accommodation',
arguments: { tripId: trip.id, accommodationId: 1, confirmation: 'X' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_accommodation
// ---------------------------------------------------------------------------
describe('Tool: delete_accommodation', () => {
it('deletes accommodation and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
const day1 = createDay(testDb, trip.id);
const day2 = createDay(testDb, trip.id);
const acc = createDayAccommodation(testDb, trip.id, place.id, day1.id, day2.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_accommodation',
arguments: { tripId: trip.id, accommodationId: acc.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:deleted', expect.objectContaining({ id: acc.id }));
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(acc.id)).toBeUndefined();
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_accommodation', arguments: { tripId: trip.id, accommodationId: 1 } });
expect(result.isError).toBe(true);
});
});
});
@@ -0,0 +1,283 @@
/**
* Unit tests for MCP notification tools:
* list_notifications, get_unread_notification_count, mark_notification_read,
* mark_notification_unread, mark_all_notifications_read, delete_notification,
* delete_all_notifications.
* Also covers the resource trek://notifications/in-app.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
// ---------------------------------------------------------------------------
// Helper: insert a notification directly into the DB
// ---------------------------------------------------------------------------
function createNotification(db: any, userId: number, overrides: any = {}) {
const r = db.prepare(
`INSERT INTO notifications (type, scope, target, recipient_id, title_key, text_key, is_read)
VALUES (?, ?, ?, ?, ?, ?, 0)`
).run(
overrides.type ?? 'simple',
overrides.scope ?? 'user',
overrides.target ?? 0,
userId,
overrides.title_key ?? 'notification.test.title',
overrides.text_key ?? 'notification.test.body'
);
return db.prepare('SELECT * FROM notifications WHERE id = ?').get(r.lastInsertRowid);
}
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withTools: false, withResources: true });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// list_notifications
// ---------------------------------------------------------------------------
describe('Tool: list_notifications', () => {
it('returns empty array initially', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_notifications', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.notifications).toEqual([]);
});
});
it('returns notifications when they exist', async () => {
const { user } = createUser(testDb);
createNotification(testDb, user.id, { title_key: 'notif.first' });
createNotification(testDb, user.id, { title_key: 'notif.second' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_notifications', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.notifications).toHaveLength(2);
});
});
it('returns only unread notifications when unread_only is true', async () => {
const { user } = createUser(testDb);
createNotification(testDb, user.id);
const read = createNotification(testDb, user.id) as any;
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(read.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_notifications', arguments: { unread_only: true } });
const data = parseToolResult(result) as any;
expect(data.notifications).toHaveLength(1);
});
});
});
// ---------------------------------------------------------------------------
// get_unread_notification_count
// ---------------------------------------------------------------------------
describe('Tool: get_unread_notification_count', () => {
it('returns 0 initially', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_unread_notification_count', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.count).toBe(0);
});
});
it('returns 1 after inserting one unread notification', async () => {
const { user } = createUser(testDb);
createNotification(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_unread_notification_count', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.count).toBe(1);
});
});
});
// ---------------------------------------------------------------------------
// mark_notification_read
// ---------------------------------------------------------------------------
describe('Tool: mark_notification_read', () => {
it('flips is_read to 1 and returns success', async () => {
const { user } = createUser(testDb);
const notif = createNotification(testDb, user.id) as any;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_notification_read',
arguments: { notificationId: notif.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id) as any;
expect(row.is_read).toBe(1);
});
});
it('returns isError for non-existent notification', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_notification_read',
arguments: { notificationId: 99999 },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const notif = createNotification(testDb, user.id) as any;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_notification_read',
arguments: { notificationId: notif.id },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// mark_notification_unread
// ---------------------------------------------------------------------------
describe('Tool: mark_notification_unread', () => {
it('flips is_read to 0', async () => {
const { user } = createUser(testDb);
const notif = createNotification(testDb, user.id) as any;
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(notif.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_notification_unread',
arguments: { notificationId: notif.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id) as any;
expect(row.is_read).toBe(0);
});
});
it('returns isError for non-existent notification', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_notification_unread',
arguments: { notificationId: 99999 },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// mark_all_notifications_read
// ---------------------------------------------------------------------------
describe('Tool: mark_all_notifications_read', () => {
it('marks all notifications read and returns count', async () => {
const { user } = createUser(testDb);
createNotification(testDb, user.id);
createNotification(testDb, user.id);
createNotification(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'mark_all_notifications_read', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(data.count).toBe(3);
const unread = (testDb.prepare('SELECT COUNT(*) as c FROM notifications WHERE recipient_id = ? AND is_read = 0').get(user.id) as any).c;
expect(unread).toBe(0);
});
});
it('returns count 0 when nothing to mark', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'mark_all_notifications_read', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.count).toBe(0);
});
});
});
// ---------------------------------------------------------------------------
// Resource: trek://notifications/in-app
// ---------------------------------------------------------------------------
describe('Resource: trek://notifications/in-app', () => {
it('returns notifications list', async () => {
const { user } = createUser(testDb);
createNotification(testDb, user.id, { title_key: 'notif.test' });
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://notifications/in-app' });
const data = parseResourceResult(result) as any;
expect(data.notifications).toBeDefined();
expect(Array.isArray(data.notifications)).toBe(true);
expect(data.notifications).toHaveLength(1);
});
});
it('returns empty notifications for user with none', async () => {
const { user } = createUser(testDb);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://notifications/in-app' });
const data = parseResourceResult(result) as any;
expect(data.notifications).toEqual([]);
});
});
});
@@ -0,0 +1,459 @@
/**
* Unit tests for MCP packing advanced tools:
* reorder_packing_items, list_packing_bags, create_packing_bag, update_packing_bag,
* delete_packing_bag, set_bag_members, get_packing_category_assignees,
* set_packing_category_assignees, apply_packing_template, save_packing_template,
* bulk_import_packing.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createPackingItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// reorder_packing_items
// ---------------------------------------------------------------------------
describe('Tool: reorder_packing_items', () => {
it('reorders packing items and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item1 = createPackingItem(testDb, trip.id, { name: 'Shirt' });
const item2 = createPackingItem(testDb, trip.id, { name: 'Pants' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'reorder_packing_items',
arguments: { tripId: trip.id, orderedIds: [item2.id, item1.id] },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:reordered', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const item = createPackingItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'reorder_packing_items',
arguments: { tripId: trip.id, orderedIds: [item.id] },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// list_packing_bags
// ---------------------------------------------------------------------------
describe('Tool: list_packing_bags', () => {
it('returns empty array initially', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'list_packing_bags',
arguments: { tripId: trip.id },
});
const data = parseToolResult(result) as any;
expect(data.bags).toEqual([]);
});
});
it('returns bags that exist', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Carry-on', '#ff0000');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'list_packing_bags',
arguments: { tripId: trip.id },
});
const data = parseToolResult(result) as any;
expect(data.bags).toHaveLength(1);
expect(data.bags[0].name).toBe('Carry-on');
});
});
});
// ---------------------------------------------------------------------------
// create_packing_bag
// ---------------------------------------------------------------------------
describe('Tool: create_packing_bag', () => {
it('creates a bag and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_packing_bag',
arguments: { tripId: trip.id, name: 'Checked bag', color: '#3b82f6' },
});
const data = parseToolResult(result) as any;
expect(data.bag).toBeDefined();
expect(data.bag.name).toBe('Checked bag');
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-created', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_packing_bag',
arguments: { tripId: trip.id, name: 'Bag' },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_packing_bag',
arguments: { tripId: trip.id, name: 'Bag' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// update_packing_bag
// ---------------------------------------------------------------------------
describe('Tool: update_packing_bag', () => {
it('updates bag name and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Old Name', '#aabbcc');
const bag = testDb.prepare('SELECT * FROM packing_bags WHERE id = ?').get(r.lastInsertRowid) as any;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_packing_bag',
arguments: { tripId: trip.id, bagId: bag.id, name: 'New Name' },
});
const data = parseToolResult(result) as any;
expect(data.bag).toBeDefined();
expect(data.bag.name).toBe('New Name');
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-updated', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_packing_bag',
arguments: { tripId: trip.id, bagId: 1, name: 'X' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_packing_bag
// ---------------------------------------------------------------------------
describe('Tool: delete_packing_bag', () => {
it('deletes a bag and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Delete Me', '#000000');
const bagId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_packing_bag',
arguments: { tripId: trip.id, bagId },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-deleted', expect.any(Object));
expect(testDb.prepare('SELECT id FROM packing_bags WHERE id = ?').get(bagId)).toBeUndefined();
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_packing_bag',
arguments: { tripId: trip.id, bagId: 1 },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// set_bag_members
// ---------------------------------------------------------------------------
describe('Tool: set_bag_members', () => {
it('sets bag members and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'My Bag', '#123456');
const bagId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_bag_members',
arguments: { tripId: trip.id, bagId, userIds: [user.id] },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-members-updated', expect.any(Object));
});
});
it('clears bag members when passed empty array', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'My Bag', '#123456');
const bagId = r.lastInsertRowid as number;
testDb.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)').run(bagId, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_bag_members',
arguments: { tripId: trip.id, bagId, userIds: [] },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// get_packing_category_assignees
// ---------------------------------------------------------------------------
describe('Tool: get_packing_category_assignees', () => {
it('returns empty object initially', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_packing_category_assignees',
arguments: { tripId: trip.id },
});
const data = parseToolResult(result) as any;
expect(data.assignees).toEqual({});
});
});
});
// ---------------------------------------------------------------------------
// set_packing_category_assignees
// ---------------------------------------------------------------------------
describe('Tool: set_packing_category_assignees', () => {
it('sets category assignees and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_packing_category_assignees',
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [user.id] },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:assignees', expect.any(Object));
});
});
it('clears assignees when passed empty array', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)').run(trip.id, 'Clothing', user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_packing_category_assignees',
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [] },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_packing_category_assignees',
arguments: { tripId: trip.id, categoryName: 'Electronics', userIds: [] },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// apply_packing_template
// ---------------------------------------------------------------------------
describe('Tool: apply_packing_template', () => {
it('returns error for non-existent template', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'apply_packing_template',
arguments: { tripId: trip.id, templateId: 99999 },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// save_packing_template
// ---------------------------------------------------------------------------
describe('Tool: save_packing_template', () => {
it('saves the current packing list as a template', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'save_packing_template',
arguments: { tripId: trip.id, templateName: 'Weekend Trip' },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'save_packing_template',
arguments: { tripId: trip.id, templateName: 'X' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// bulk_import_packing
// ---------------------------------------------------------------------------
describe('Tool: bulk_import_packing', () => {
it('imports multiple packing items and count matches', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const items = [
{ name: 'Passport', category: 'Documents' },
{ name: 'Charger', category: 'Electronics' },
{ name: 'Sunscreen', category: 'Toiletries' },
];
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'bulk_import_packing',
arguments: { tripId: trip.id, items },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(data.count).toBe(items.length);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'bulk_import_packing',
arguments: { tripId: trip.id, items: [{ name: 'Item' }] },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'bulk_import_packing',
arguments: { tripId: trip.id, items: [{ name: 'Item' }] },
});
expect(result.isError).toBe(true);
});
});
});
+94 -1
View File
@@ -43,7 +43,7 @@ vi.mock('../../../src/services/mapsService', () => ({ searchPlaces: searchPlaces
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createPlace } from '../../helpers/factories';
import { createUser, createTrip, createPlace, createDay } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
@@ -321,3 +321,96 @@ describe('Tool: search_place', () => {
});
});
});
// ---------------------------------------------------------------------------
// list_places
// ---------------------------------------------------------------------------
describe('Tool: list_places', () => {
it('returns all places by default', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place1 = createPlace(testDb, trip.id, { name: 'Orphan Place' });
const place2 = createPlace(testDb, trip.id, { name: 'Assigned Place' });
const day = createDay(testDb, trip.id);
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, place2.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.places).toHaveLength(2);
});
});
it('returns only unassigned places with assignment=unassigned', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const orphan = createPlace(testDb, trip.id, { name: 'Orphan Place' });
const assigned = createPlace(testDb, trip.id, { name: 'Assigned Place' });
const day = createDay(testDb, trip.id);
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned' } });
const data = parseToolResult(result) as any;
expect(data.places).toHaveLength(1);
expect(data.places[0].name).toBe('Orphan Place');
});
});
it('returns only assigned places with assignment=assigned', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const orphan = createPlace(testDb, trip.id, { name: 'Orphan Place' });
const assigned = createPlace(testDb, trip.id, { name: 'Assigned Place' });
const day = createDay(testDb, trip.id);
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'assigned' } });
const data = parseToolResult(result) as any;
expect(data.places).toHaveLength(1);
expect(data.places[0].name).toBe('Assigned Place');
});
});
it('returns empty array when all places are assigned', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Only Place' });
const day = createDay(testDb, trip.id);
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned' } });
const data = parseToolResult(result) as any;
expect(data.places).toHaveLength(0);
});
});
it('composes with search filter', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const orphan = createPlace(testDb, trip.id, { name: 'Louvre Museum' });
const assigned = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
const day = createDay(testDb, trip.id);
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned', search: 'Louvre' } });
const data = parseToolResult(result) as any;
expect(data.places).toHaveLength(1);
expect(data.places[0].name).toBe('Louvre Museum');
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
@@ -0,0 +1,312 @@
/**
* Unit tests for MCP tag, maps extras, and weather tools:
* list_tags, create_tag, update_tag, delete_tag,
* get_place_details, reverse_geocode, resolve_maps_url,
* get_weather, get_detailed_weather.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
vi.mock('../../../src/services/mapsService', () => ({
searchPlaces: vi.fn(),
getPlaceDetails: vi.fn().mockResolvedValue({ name: 'Eiffel Tower', address: 'Paris' }),
reverseGeocode: vi.fn().mockResolvedValue({ name: 'Paris', address: 'France' }),
resolveGoogleMapsUrl: vi.fn().mockResolvedValue({ lat: 48.8566, lng: 2.3522, name: 'Paris' }),
}));
vi.mock('../../../src/services/weatherService', () => ({
getWeather: vi.fn().mockResolvedValue({ temp: 20, condition: 'sunny' }),
getDetailedWeather: vi.fn().mockResolvedValue({ hourly: [] }),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import * as mapsService from '../../../src/services/mapsService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// list_tags
// ---------------------------------------------------------------------------
describe('Tool: list_tags', () => {
it('returns empty array initially', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_tags', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.tags).toEqual([]);
});
});
it('returns only tags belonging to the current user', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'My Tag', '#ff0000');
testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(other.id, 'Other Tag', '#00ff00');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_tags', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.tags).toHaveLength(1);
expect(data.tags[0].name).toBe('My Tag');
});
});
});
// ---------------------------------------------------------------------------
// create_tag
// ---------------------------------------------------------------------------
describe('Tool: create_tag', () => {
it('creates a tag and returns the tag object', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_tag',
arguments: { name: 'Adventure', color: '#ff5500' },
});
const data = parseToolResult(result) as any;
expect(data.tag).toBeDefined();
expect(data.tag.name).toBe('Adventure');
expect(data.tag.color).toBe('#ff5500');
expect(data.tag.user_id).toBe(user.id);
});
});
it('creates a tag with only a name', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_tag',
arguments: { name: 'Food' },
});
const data = parseToolResult(result) as any;
expect(data.tag.name).toBe('Food');
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_tag',
arguments: { name: 'Blocked' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// update_tag
// ---------------------------------------------------------------------------
describe('Tool: update_tag', () => {
it('updates tag name and color', async () => {
const { user } = createUser(testDb);
const r = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'Old Name', '#aaaaaa');
const tagId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_tag',
arguments: { tagId, name: 'New Name', color: '#bbbbbb' },
});
const data = parseToolResult(result) as any;
expect(data.tag).toBeDefined();
expect(data.tag.name).toBe('New Name');
expect(data.tag.color).toBe('#bbbbbb');
});
});
it('returns isError for non-existent tagId', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_tag',
arguments: { tagId: 99999, name: 'X' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_tag
// ---------------------------------------------------------------------------
describe('Tool: delete_tag', () => {
it('removes the tag row', async () => {
const { user } = createUser(testDb);
const r = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'To Delete', '#cccccc');
const tagId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_tag',
arguments: { tagId },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(tagId)).toBeUndefined();
});
});
});
// ---------------------------------------------------------------------------
// get_place_details
// ---------------------------------------------------------------------------
describe('Tool: get_place_details', () => {
it('returns details from mocked service', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_place_details',
arguments: { placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ' },
});
const data = parseToolResult(result) as any;
expect(data.details).toBeDefined();
expect(data.details.name).toBe('Eiffel Tower');
});
});
it('returns isError when service returns null', async () => {
const { getPlaceDetails } = await import('../../../src/services/mapsService');
(getPlaceDetails as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_place_details',
arguments: { placeId: 'nonexistent-place-id' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// reverse_geocode
// ---------------------------------------------------------------------------
describe('Tool: reverse_geocode', () => {
it('returns result from mocked service', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'reverse_geocode',
arguments: { lat: 48.8566, lng: 2.3522 },
});
const data = parseToolResult(result) as any;
expect(data.name).toBe('Paris');
expect(data.address).toBe('France');
});
});
});
// ---------------------------------------------------------------------------
// resolve_maps_url
// ---------------------------------------------------------------------------
describe('Tool: resolve_maps_url', () => {
it('returns result from mocked service', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'resolve_maps_url',
arguments: { url: 'https://maps.app.goo.gl/example' },
});
const data = parseToolResult(result) as any;
expect(data.lat).toBe(48.8566);
expect(data.lng).toBe(2.3522);
expect(data.name).toBe('Paris');
});
});
});
// ---------------------------------------------------------------------------
// get_weather
// ---------------------------------------------------------------------------
describe('Tool: get_weather', () => {
it('returns weather from mocked service', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_weather',
arguments: { lat: 48.8566, lng: 2.3522, date: '2025-07-01' },
});
const data = parseToolResult(result) as any;
expect(data.weather).toBeDefined();
expect(data.weather.temp).toBe(20);
expect(data.weather.condition).toBe('sunny');
});
});
});
// ---------------------------------------------------------------------------
// get_detailed_weather
// ---------------------------------------------------------------------------
describe('Tool: get_detailed_weather', () => {
it('returns detailed weather from mocked service', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_detailed_weather',
arguments: { lat: 48.8566, lng: 2.3522, date: '2025-07-01' },
});
const data = parseToolResult(result) as any;
expect(data.weather).toBeDefined();
expect(Array.isArray(data.weather.hourly)).toBe(true);
});
});
});
+438
View File
@@ -0,0 +1,438 @@
/**
* Unit tests for MCP todo tools:
* create_todo, update_todo, toggle_todo, delete_todo, reorder_todos,
* list_todos, get_todo_category_assignees, set_todo_category_assignees.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createTodoItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// list_todos
// ---------------------------------------------------------------------------
describe('Tool: list_todos', () => {
it('returns empty list for a new trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_todos', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.items).toEqual([]);
});
});
it('returns todos ordered by sort_order', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createTodoItem(testDb, trip.id, { name: 'First' });
createTodoItem(testDb, trip.id, { name: 'Second' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_todos', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.items).toHaveLength(2);
expect(data.items[0].name).toBe('First');
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_todos', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// create_todo
// ---------------------------------------------------------------------------
describe('Tool: create_todo', () => {
it('creates a todo item with all fields', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_todo',
arguments: {
tripId: trip.id,
name: 'Book hotel',
category: 'Booking',
due_date: '2025-06-01',
description: 'Find a good deal',
priority: 2,
},
});
const data = parseToolResult(result) as any;
expect(data.item.name).toBe('Book hotel');
expect(data.item.category).toBe('Booking');
expect(data.item.due_date).toBe('2025-06-01');
expect(data.item.priority).toBe(2);
expect(data.item.checked).toBe(0);
});
});
it('creates a minimal todo item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_todo',
arguments: { tripId: trip.id, name: 'Pack bags' },
});
const data = parseToolResult(result) as any;
expect(data.item.name).toBe('Pack bags');
expect(data.item.checked).toBe(0);
});
});
it('broadcasts todo:created event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'create_todo', arguments: { tripId: trip.id, name: 'Test' } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:created', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_todo', arguments: { tripId: trip.id, name: 'X' } });
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_todo', arguments: { tripId: trip.id, name: 'X' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// update_todo
// ---------------------------------------------------------------------------
describe('Tool: update_todo', () => {
it('updates todo name and category', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createTodoItem(testDb, trip.id, { name: 'Old name', category: 'General' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_todo',
arguments: { tripId: trip.id, itemId: item.id, name: 'New name', category: 'Booking' },
});
const data = parseToolResult(result) as any;
expect(data.item.name).toBe('New name');
expect(data.item.category).toBe('Booking');
});
});
it('clears due_date when passed null', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare("INSERT INTO todo_items (trip_id, name, checked, sort_order, due_date) VALUES (?, 'Task', 0, 0, '2025-01-01')").run(trip.id);
const item = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as any;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_todo',
arguments: { tripId: trip.id, itemId: item.id, due_date: null },
});
const data = parseToolResult(result) as any;
expect(data.item.due_date).toBeNull();
});
});
it('broadcasts todo:updated event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createTodoItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:updated', expect.any(Object));
});
});
it('returns error for item not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: 99999, name: 'X' } });
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const item = createTodoItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: item.id, name: 'X' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// toggle_todo
// ---------------------------------------------------------------------------
describe('Tool: toggle_todo', () => {
it('marks a todo as done', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createTodoItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'toggle_todo',
arguments: { tripId: trip.id, itemId: item.id, checked: true },
});
const data = parseToolResult(result) as any;
expect(data.item.checked).toBe(1);
});
});
it('unchecks a done todo', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createTodoItem(testDb, trip.id, { checked: 1 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'toggle_todo',
arguments: { tripId: trip.id, itemId: item.id, checked: false },
});
const data = parseToolResult(result) as any;
expect(data.item.checked).toBe(0);
});
});
it('broadcasts todo:updated event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createTodoItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'toggle_todo', arguments: { tripId: trip.id, itemId: item.id, checked: true } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:updated', expect.any(Object));
});
});
it('returns error for item not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'toggle_todo', arguments: { tripId: trip.id, itemId: 99999, checked: true } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_todo
// ---------------------------------------------------------------------------
describe('Tool: delete_todo', () => {
it('deletes an existing todo item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createTodoItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM todo_items WHERE id = ?').get(item.id)).toBeUndefined();
});
});
it('broadcasts todo:deleted event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createTodoItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:deleted', expect.any(Object));
});
});
it('returns error for item not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: 99999 } });
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const item = createTodoItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// reorder_todos
// ---------------------------------------------------------------------------
describe('Tool: reorder_todos', () => {
it('reorders todo items', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item1 = createTodoItem(testDb, trip.id, { name: 'First' });
const item2 = createTodoItem(testDb, trip.id, { name: 'Second' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'reorder_todos',
arguments: { tripId: trip.id, orderedIds: [item2.id, item1.id] },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
// item2 should now have sort_order 0
const updated = testDb.prepare('SELECT sort_order FROM todo_items WHERE id = ?').get(item2.id) as any;
expect(updated.sort_order).toBe(0);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'reorder_todos', arguments: { tripId: trip.id, orderedIds: [1] } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// get_todo_category_assignees
// ---------------------------------------------------------------------------
describe('Tool: get_todo_category_assignees', () => {
it('returns empty object for a new trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_todo_category_assignees', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.assignees).toEqual({});
});
});
});
// ---------------------------------------------------------------------------
// set_todo_category_assignees
// ---------------------------------------------------------------------------
describe('Tool: set_todo_category_assignees', () => {
it('sets category assignees and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_todo_category_assignees',
arguments: { tripId: trip.id, categoryName: 'Booking', userIds: [user.id] },
});
const data = parseToolResult(result) as any;
expect(Array.isArray(data.assignees)).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:assignees', expect.any(Object));
});
});
it('clears assignees when passed empty array', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Set then clear
testDb.prepare('INSERT INTO todo_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)').run(trip.id, 'Booking', user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_todo_category_assignees',
arguments: { tripId: trip.id, categoryName: 'Booking', userIds: [] },
});
const data = parseToolResult(result) as any;
expect(data.assignees).toEqual([]);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_todo_category_assignees',
arguments: { tripId: trip.id, categoryName: 'Test', userIds: [] },
});
expect(result.isError).toBe(true);
});
});
});
@@ -0,0 +1,378 @@
/**
* Unit tests for MCP trip member, copy, ICS, and share-link tools:
* list_trip_members, add_trip_member, remove_trip_member,
* copy_trip, export_trip_ics, get_share_link, create_share_link, delete_share_link.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// list_trip_members
// ---------------------------------------------------------------------------
describe('Tool: list_trip_members', () => {
it('returns owner and empty members list for own trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_trip_members', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.owner.id).toBe(user.id);
expect(data.owner.role).toBe('owner');
expect(Array.isArray(data.members)).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_trip_members', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// add_trip_member
// ---------------------------------------------------------------------------
describe('Tool: add_trip_member', () => {
it('adds a member by username', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: member.username },
});
const data = parseToolResult(result) as any;
expect(data.member.username).toBe(member.username);
expect(data.member.role).toBe('member');
});
});
it('broadcasts member:added event', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: member.email },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'member:added', expect.any(Object));
});
});
it('returns error when user not found', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: 'nonexistent@example.com' },
});
expect(result.isError).toBe(true);
});
});
it('returns error when non-owner tries to add', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const { user: outsider } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(member.id, async (h) => {
const result = await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: outsider.username },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: 'someone@example.com' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// remove_trip_member
// ---------------------------------------------------------------------------
describe('Tool: remove_trip_member', () => {
it('removes a member', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({
name: 'remove_trip_member',
arguments: { tripId: trip.id, memberId: member.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id);
expect(row).toBeUndefined();
});
});
it('broadcasts member:removed event', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(owner.id, async (h) => {
await h.client.callTool({ name: 'remove_trip_member', arguments: { tripId: trip.id, memberId: member.id } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'member:removed', expect.any(Object));
});
});
it('returns error when non-owner tries to remove', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(member.id, async (h) => {
const result = await h.client.callTool({
name: 'remove_trip_member',
arguments: { tripId: trip.id, memberId: owner.id },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// copy_trip
// ---------------------------------------------------------------------------
describe('Tool: copy_trip', () => {
it('duplicates a trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Original', start_date: '2025-01-01', end_date: '2025-01-03' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.trip).toBeTruthy();
// New trip should be a different row
const count = testDb.prepare('SELECT COUNT(*) as cnt FROM trips').get() as any;
expect(count.cnt).toBe(2);
});
});
it('uses custom title when provided', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Original' });
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id, title: 'My Copy' } });
const newTrip = testDb.prepare("SELECT * FROM trips WHERE title = 'My Copy'").get() as any;
expect(newTrip).toBeTruthy();
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// export_trip_ics
// ---------------------------------------------------------------------------
describe('Tool: export_trip_ics', () => {
it('returns ICS content for a trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip', start_date: '2025-06-01', end_date: '2025-06-05' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'export_trip_ics', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.ics).toContain('BEGIN:VCALENDAR');
expect(data.ics).toContain('Paris Trip');
expect(data.filename).toMatch(/\.ics$/);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'export_trip_ics', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// get_share_link / create_share_link / delete_share_link
// ---------------------------------------------------------------------------
describe('Tool: get_share_link', () => {
it('returns null when no share link exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.link).toBeNull();
});
});
it('returns share link info when it exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Create a share link directly
testDb.prepare(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
).run(trip.id, 'test-token-123', user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.link.token).toBe('test-token-123');
expect(data.link.share_map).toBe(true);
});
});
});
describe('Tool: create_share_link', () => {
it('creates a new share link', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_share_link',
arguments: { tripId: trip.id, share_map: true, share_bookings: false, share_packing: false },
});
const data = parseToolResult(result) as any;
expect(data.token).toBeTruthy();
expect(data.created).toBe(true);
});
});
it('updates existing share link permissions', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
).run(trip.id, 'existing-token', user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_share_link',
arguments: { tripId: trip.id, share_packing: true },
});
const data = parseToolResult(result) as any;
expect(data.created).toBe(false); // updated, not created
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
describe('Tool: delete_share_link', () => {
it('revokes the share link', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
).run(trip.id, 'to-delete', user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_share_link', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(trip.id);
expect(row).toBeUndefined();
});
});
});
+14
View File
@@ -337,4 +337,18 @@ describe('Tool: get_trip_summary', () => {
expect(data.trip.title).toBe('Demo Trip');
});
});
it('includes todos, files, pollCount, messageCount in response', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Summary Test' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_trip_summary', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(Array.isArray(data.todos)).toBe(true);
expect(Array.isArray(data.files)).toBe(true);
expect(typeof data.pollCount).toBe('number');
expect(typeof data.messageCount).toBe('number');
});
});
});
+477
View File
@@ -0,0 +1,477 @@
/**
* Unit tests for MCP vacay tools (vacay addon-gated):
* get_vacay_plan, update_vacay_plan, set_vacay_color,
* list_vacay_years, add_vacay_year, delete_vacay_year,
* get_vacay_entries, toggle_vacay_entry, toggle_company_holiday,
* get_vacay_stats, update_vacay_stats,
* add_holiday_calendar, update_holiday_calendar, delete_holiday_calendar,
* list_holiday_countries, list_holidays.
* Resources: trek://vacay/plan, trek://vacay/entries/{year}.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
vi.mock('../../../src/services/adminService', () => ({
isAddonEnabled: vi.fn().mockReturnValue(true),
}));
// Mock async service functions that make external calls
vi.mock('../../../src/services/vacayService', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
return {
...original,
updatePlan: vi.fn().mockResolvedValue(undefined),
getCountries: vi.fn().mockResolvedValue({ data: [{ code: 'US', name: 'United States' }] }),
getHolidays: vi.fn().mockResolvedValue({ data: [{ date: '2025-01-01', name: 'New Year' }] }),
};
});
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: true });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// get_vacay_plan
// ---------------------------------------------------------------------------
describe('Tool: get_vacay_plan', () => {
it('returns plan data object', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_vacay_plan', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.plan).toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
// update_vacay_plan
// ---------------------------------------------------------------------------
describe('Tool: update_vacay_plan', () => {
it('calls updatePlan and returns success', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_vacay_plan',
arguments: { block_weekends: true, holidays_enabled: false },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_vacay_plan', arguments: { block_weekends: true } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// set_vacay_color
// ---------------------------------------------------------------------------
describe('Tool: set_vacay_color', () => {
it('updates color and returns success', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#6366f1' } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#ff0000' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// list_vacay_years
// ---------------------------------------------------------------------------
describe('Tool: list_vacay_years', () => {
it('returns years array', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_vacay_years', arguments: {} });
const data = parseToolResult(result) as any;
expect(Array.isArray(data.years)).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// add_vacay_year
// ---------------------------------------------------------------------------
describe('Tool: add_vacay_year', () => {
it('adds year to list', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } });
const data = parseToolResult(result) as any;
expect(Array.isArray(data.years)).toBe(true);
expect(data.years).toContain(2025);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_vacay_year
// ---------------------------------------------------------------------------
describe('Tool: delete_vacay_year', () => {
it('removes year from list', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
// Add year first
await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } });
const result = await h.client.callTool({ name: 'delete_vacay_year', arguments: { year: 2025 } });
const data = parseToolResult(result) as any;
expect(Array.isArray(data.years)).toBe(true);
expect(data.years).not.toContain(2025);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_vacay_year', arguments: { year: 2025 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// get_vacay_entries
// ---------------------------------------------------------------------------
describe('Tool: get_vacay_entries', () => {
it('returns entries array (empty initially)', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_vacay_entries', arguments: { year: 2025 } });
const data = parseToolResult(result) as any;
expect(data.entries).toBeDefined();
expect(Array.isArray(data.entries.entries)).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// toggle_vacay_entry
// ---------------------------------------------------------------------------
describe('Tool: toggle_vacay_entry', () => {
it('toggles entry and returns action', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-15' } });
const data = parseToolResult(result) as any;
expect(data.action).toBeDefined();
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-15' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// toggle_company_holiday
// ---------------------------------------------------------------------------
describe('Tool: toggle_company_holiday', () => {
it('toggles company holiday and returns action', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'toggle_company_holiday',
arguments: { date: '2025-12-25', note: 'Christmas' },
});
const data = parseToolResult(result) as any;
expect(data.action).toBeDefined();
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'toggle_company_holiday', arguments: { date: '2025-12-25' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// get_vacay_stats
// ---------------------------------------------------------------------------
describe('Tool: get_vacay_stats', () => {
it('returns stats object', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_vacay_stats', arguments: { year: 2025 } });
const data = parseToolResult(result) as any;
expect(data.stats).toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
// update_vacay_stats
// ---------------------------------------------------------------------------
describe('Tool: update_vacay_stats', () => {
it('updates vacation days allowance and returns success', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_vacay_stats', arguments: { year: 2025, vacationDays: 25 } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_vacay_stats', arguments: { year: 2025, vacationDays: 20 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// add_holiday_calendar
// ---------------------------------------------------------------------------
describe('Tool: add_holiday_calendar', () => {
it('inserts calendar row and returns calendar', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'add_holiday_calendar',
arguments: { region: 'US', label: 'US Holidays', color: '#ff0000' },
});
const data = parseToolResult(result) as any;
expect(data.calendar).toBeDefined();
expect(data.calendar.region).toBe('US');
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'add_holiday_calendar', arguments: { region: 'US' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// update_holiday_calendar
// ---------------------------------------------------------------------------
describe('Tool: update_holiday_calendar', () => {
it('updates label and color', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
// First add a calendar
const addResult = await h.client.callTool({
name: 'add_holiday_calendar',
arguments: { region: 'DE', label: 'Germany' },
});
const added = parseToolResult(addResult) as any;
const calId = added.calendar.id;
const result = await h.client.callTool({
name: 'update_holiday_calendar',
arguments: { calendarId: calId, label: 'German Holidays', color: '#00ff00' },
});
const data = parseToolResult(result) as any;
expect(data.calendar).toBeDefined();
expect(data.calendar.label).toBe('German Holidays');
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_holiday_calendar', arguments: { calendarId: 1, label: 'X' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_holiday_calendar
// ---------------------------------------------------------------------------
describe('Tool: delete_holiday_calendar', () => {
it('removes calendar and returns success', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const addResult = await h.client.callTool({
name: 'add_holiday_calendar',
arguments: { region: 'FR' },
});
const added = parseToolResult(addResult) as any;
const calId = added.calendar.id;
const result = await h.client.callTool({ name: 'delete_holiday_calendar', arguments: { calendarId: calId } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_holiday_calendar', arguments: { calendarId: 1 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// list_holiday_countries
// ---------------------------------------------------------------------------
describe('Tool: list_holiday_countries', () => {
it('returns countries from mocked service', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_holiday_countries', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.countries).toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
// list_holidays
// ---------------------------------------------------------------------------
describe('Tool: list_holidays', () => {
it('returns holidays from mocked service', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_holidays', arguments: { country: 'US', year: 2025 } });
const data = parseToolResult(result) as any;
expect(data.holidays).toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
describe('Resource: trek://vacay/plan', () => {
it('returns plan data', async () => {
const { user } = createUser(testDb);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://vacay/plan' });
const data = parseResourceResult(result) as any;
expect(data).toBeDefined();
});
});
});
describe('Resource: trek://vacay/entries/{year}', () => {
it('returns entries for a year', async () => {
const { user } = createUser(testDb);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://vacay/entries/2025' });
const data = parseResourceResult(result) as any;
expect(data).toBeDefined();
expect(Array.isArray(data.entries)).toBe(true);
});
});
});
+54 -2
View File
@@ -1,11 +1,13 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import jwt from 'jsonwebtoken';
vi.mock('../../../src/db/database', () => ({
db: { prepare: () => ({ get: vi.fn(), all: vi.fn() }) },
db: { prepare: vi.fn(() => ({ get: vi.fn(), all: vi.fn() })) },
}));
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret' }));
import { extractToken, authenticate, adminOnly } from '../../../src/middleware/auth';
import { db } from '../../../src/db/database';
import type { Request, Response, NextFunction } from 'express';
function makeReq(overrides: {
@@ -82,6 +84,56 @@ describe('authenticate', () => {
expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(401);
});
it('AUTH-MW-003: calls next() and sets req.user for a valid JWT', () => {
const mockUser = { id: 1, username: 'alice', email: 'alice@example.com', role: 'user' };
vi.mocked(db.prepare).mockReturnValue({ get: vi.fn(() => mockUser), all: vi.fn() } as any);
const token = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256' });
const req = makeReq({ cookies: { trek_session: token } });
const next = vi.fn() as unknown as NextFunction;
const { res } = makeRes();
authenticate(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect((req as any).user).toEqual(mockUser);
});
it('AUTH-MW-004: returns 401 for a valid JWT when user does not exist in DB', () => {
vi.mocked(db.prepare).mockReturnValue({ get: vi.fn(() => undefined), all: vi.fn() } as any);
const token = jwt.sign({ id: 99999 }, 'test-secret', { algorithm: 'HS256' });
const next = vi.fn() as unknown as NextFunction;
const { res, status } = makeRes();
authenticate(makeReq({ cookies: { trek_session: token } }), res, next);
expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(401);
});
it('AUTH-MW-005: returns 401 for an expired JWT', () => {
const expiredToken = jwt.sign(
{ id: 1, exp: Math.floor(Date.now() / 1000) - 3600 },
'test-secret',
{ algorithm: 'HS256' }
);
const next = vi.fn() as unknown as NextFunction;
const { res, status } = makeRes();
authenticate(makeReq({ cookies: { trek_session: expiredToken } }), res, next);
expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(401);
});
it('AUTH-MW-006: returns 401 for a JWT signed with the wrong secret', () => {
const tamperedToken = jwt.sign({ id: 1 }, 'wrong-secret', { algorithm: 'HS256' });
const next = vi.fn() as unknown as NextFunction;
const { res, status } = makeRes();
authenticate(makeReq({ cookies: { trek_session: tamperedToken } }), res, next);
expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(401);
});
});
// ── adminOnly ─────────────────────────────────────────────────────────────────
@@ -0,0 +1,137 @@
/**
* Unit tests for requireTripAccess and requireTripOwner middleware.
* TRIP-ACCESS-001 through TRIP-ACCESS-010.
* canAccessTrip and isOwner are mocked; no DB required.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response, NextFunction } from 'express';
const mockCanAccessTrip = vi.fn();
const mockIsOwner = vi.fn();
vi.mock('../../../src/db/database', () => ({
canAccessTrip: (...args: any[]) => mockCanAccessTrip(...args),
isOwner: (...args: any[]) => mockIsOwner(...args),
}));
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret' }));
import { requireTripAccess, requireTripOwner } from '../../../src/middleware/tripAccess';
function makeRes(): { res: Response; status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn> } {
const json = vi.fn();
const status = vi.fn(() => ({ json }));
const res = { status } as unknown as Response;
return { res, status, json };
}
function makeReq(params: Record<string, string> = {}, userId = 1): Request {
return {
params,
user: { id: userId },
} as unknown as Request;
}
beforeEach(() => {
mockCanAccessTrip.mockReset();
mockIsOwner.mockReset();
});
// ── requireTripAccess ─────────────────────────────────────────────────────────
describe('requireTripAccess', () => {
it('TRIP-ACCESS-001: returns 400 when no tripId param', () => {
const next = vi.fn() as unknown as NextFunction;
const { res, status, json } = makeRes();
requireTripAccess(makeReq({}), res, next);
expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
});
it('TRIP-ACCESS-002: returns 404 when canAccessTrip returns null (not a member)', () => {
mockCanAccessTrip.mockReturnValue(null);
const next = vi.fn() as unknown as NextFunction;
const { res, status, json } = makeRes();
requireTripAccess(makeReq({ tripId: '42' }), res, next);
expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(404);
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
});
it('TRIP-ACCESS-003: calls next and attaches trip when user has access', () => {
const fakeTrip = { id: 42, user_id: 1 };
mockCanAccessTrip.mockReturnValue(fakeTrip);
const next = vi.fn() as unknown as NextFunction;
const { res } = makeRes();
const req = makeReq({ tripId: '42' }, 1);
requireTripAccess(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect((req as any).trip).toEqual(fakeTrip);
});
it('TRIP-ACCESS-004: accepts req.params.id as fallback when tripId is absent', () => {
const fakeTrip = { id: 7, user_id: 2 };
mockCanAccessTrip.mockReturnValue(fakeTrip);
const next = vi.fn() as unknown as NextFunction;
const { res } = makeRes();
requireTripAccess(makeReq({ id: '7' }), res, next);
expect(mockCanAccessTrip).toHaveBeenCalledWith(7, expect.any(Number));
expect(next).toHaveBeenCalledOnce();
});
it('TRIP-ACCESS-005: passes numeric tripId to canAccessTrip', () => {
mockCanAccessTrip.mockReturnValue({ id: 99, user_id: 3 });
const next = vi.fn() as unknown as NextFunction;
const { res } = makeRes();
requireTripAccess(makeReq({ tripId: '99' }, 3), res, next);
expect(mockCanAccessTrip).toHaveBeenCalledWith(99, 3);
});
});
// ── requireTripOwner ──────────────────────────────────────────────────────────
describe('requireTripOwner', () => {
it('TRIP-ACCESS-006: returns 400 when no tripId param', () => {
const next = vi.fn() as unknown as NextFunction;
const { res, status, json } = makeRes();
requireTripOwner(makeReq({}), res, next);
expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
});
it('TRIP-ACCESS-007: returns 403 when user is not the owner', () => {
mockIsOwner.mockReturnValue(false);
const next = vi.fn() as unknown as NextFunction;
const { res, status, json } = makeRes();
requireTripOwner(makeReq({ tripId: '10' }, 2), res, next);
expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(403);
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
});
it('TRIP-ACCESS-008: calls next when user is the owner', () => {
mockIsOwner.mockReturnValue(true);
const next = vi.fn() as unknown as NextFunction;
const { res } = makeRes();
requireTripOwner(makeReq({ tripId: '10' }, 1), res, next);
expect(next).toHaveBeenCalledOnce();
});
it('TRIP-ACCESS-009: accepts req.params.id as fallback when tripId is absent', () => {
mockIsOwner.mockReturnValue(true);
const next = vi.fn() as unknown as NextFunction;
const { res } = makeRes();
requireTripOwner(makeReq({ id: '5' }, 1), res, next);
expect(mockIsOwner).toHaveBeenCalledWith(5, 1);
expect(next).toHaveBeenCalledOnce();
});
it('TRIP-ACCESS-010: passes numeric tripId to isOwner', () => {
mockIsOwner.mockReturnValue(true);
const next = vi.fn() as unknown as NextFunction;
const { res } = makeRes();
requireTripOwner(makeReq({ tripId: '77' }, 4), res, next);
expect(mockIsOwner).toHaveBeenCalledWith(77, 4);
});
});
@@ -0,0 +1,700 @@
/**
* Unit tests for adminService ADMIN-SVC-001 through ADMIN-SVC-050.
* Uses a real in-memory SQLite DB. Focuses on validation/error branches
* that the integration tests don't exercise.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/services/apiKeyCrypto', () => ({
encrypt_api_key: (v: string) => v,
decrypt_api_key: (v: string) => v,
maybe_encrypt_api_key: (v: string) => v,
}));
vi.mock('../../../src/mcp', () => ({
revokeUserSessions: vi.fn(),
}));
vi.mock('../../../src/demo/demo-reset', () => ({
saveBaseline: vi.fn(),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, createInviteToken } from '../../helpers/factories';
import {
listUsers,
createUser as svcCreateUser,
updateUser,
deleteUser,
getStats,
getPermissions,
savePermissions,
getAuditLog,
listInvites,
createInvite,
deleteInvite,
getBagTracking,
updateBagTracking,
listPackingTemplates,
createPackingTemplate,
updatePackingTemplate,
deletePackingTemplate,
createTemplateCategory,
updateTemplateCategory,
deleteTemplateCategory,
getPackingTemplate,
createTemplateItem,
updateTemplateItem,
deleteTemplateItem,
getOidcSettings,
updateOidcSettings,
saveDemoBaseline,
getGithubReleases,
checkVersion,
listAddons,
updateAddon,
listMcpTokens,
deleteMcpToken,
} from '../../../src/services/adminService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── listUsers ─────────────────────────────────────────────────────────────────
describe('listUsers', () => {
it('ADMIN-SVC-001 — returns all users with online:false', () => {
createUser(testDb);
createUser(testDb);
const users = listUsers() as any[];
expect(users.length).toBeGreaterThanOrEqual(2);
expect(users.every((u: any) => u.online === false)).toBe(true);
});
});
// ── createUser ────────────────────────────────────────────────────────────────
describe('createUser (service)', () => {
it('ADMIN-SVC-002 — creates a user successfully', () => {
const result = svcCreateUser({ username: 'newuser', email: 'new@test.com', password: 'ValidPass1!' }) as any;
expect(result.user).toBeDefined();
expect(result.user.email).toBe('new@test.com');
});
it('ADMIN-SVC-003 — returns 400 when username is missing', () => {
const result = svcCreateUser({ username: '', email: 'x@x.com', password: 'ValidPass1!' }) as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-004 — returns 400 for invalid role', () => {
const result = svcCreateUser({ username: 'u1', email: 'u1@test.com', password: 'ValidPass1!', role: 'superuser' }) as any;
expect(result.status).toBe(400);
expect(result.error).toMatch(/invalid role/i);
});
it('ADMIN-SVC-005 — returns 409 for duplicate username', () => {
createUser(testDb);
const { user } = createUser(testDb);
const result = svcCreateUser({ username: user.username, email: 'unique@test.com', password: 'ValidPass1!' }) as any;
expect(result.status).toBe(409);
});
it('ADMIN-SVC-006 — returns 409 for duplicate email', () => {
const { user } = createUser(testDb);
const result = svcCreateUser({ username: 'uniqueuser', email: user.email, password: 'ValidPass1!' }) as any;
expect(result.status).toBe(409);
});
it('ADMIN-SVC-007 — returns 400 for weak password', () => {
const result = svcCreateUser({ username: 'weakpwuser', email: 'weakpw@test.com', password: 'short' }) as any;
expect(result.status).toBe(400);
});
});
// ── updateUser ────────────────────────────────────────────────────────────────
describe('updateUser', () => {
it('ADMIN-SVC-008 — updates username successfully', () => {
const { user } = createUser(testDb);
const result = updateUser(String(user.id), { username: 'updatedname' }) as any;
expect(result.user).toBeDefined();
expect(result.user.username).toBe('updatedname');
});
it('ADMIN-SVC-009 — returns 404 for non-existent user', () => {
const result = updateUser('99999', { username: 'ghost' }) as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-010 — returns 400 for invalid role', () => {
const { user } = createUser(testDb);
const result = updateUser(String(user.id), { role: 'superadmin' }) as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-011 — returns 409 when username is taken', () => {
const { user: u1 } = createUser(testDb);
const { user: u2 } = createUser(testDb);
const result = updateUser(String(u2.id), { username: u1.username }) as any;
expect(result.status).toBe(409);
});
it('ADMIN-SVC-012 — returns 409 when email is taken', () => {
const { user: u1 } = createUser(testDb);
const { user: u2 } = createUser(testDb);
const result = updateUser(String(u2.id), { email: u1.email }) as any;
expect(result.status).toBe(409);
});
it('ADMIN-SVC-013 — returns 400 for weak password', () => {
const { user } = createUser(testDb);
const result = updateUser(String(user.id), { password: 'weak' }) as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-014 — tracks changed fields in result', () => {
const { user } = createUser(testDb);
const result = updateUser(String(user.id), { username: 'newname', role: 'admin' }) as any;
expect(result.changed).toContain('username');
expect(result.changed).toContain('role');
});
});
// ── deleteUser ────────────────────────────────────────────────────────────────
describe('deleteUser', () => {
it('ADMIN-SVC-015 — deletes user successfully', () => {
const { user: admin } = createAdmin(testDb);
const { user } = createUser(testDb);
const result = deleteUser(String(user.id), admin.id) as any;
expect(result.email).toBe(user.email);
});
it('ADMIN-SVC-016 — returns 400 when deleting own account', () => {
const { user: admin } = createAdmin(testDb);
const result = deleteUser(String(admin.id), admin.id) as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-017 — returns 404 for non-existent user', () => {
const { user: admin } = createAdmin(testDb);
const result = deleteUser('99999', admin.id) as any;
expect(result.status).toBe(404);
});
});
// ── getStats ──────────────────────────────────────────────────────────────────
describe('getStats', () => {
it('ADMIN-SVC-018 — returns numeric counts for all stats', () => {
const stats = getStats() as any;
expect(typeof stats.totalUsers).toBe('number');
expect(typeof stats.totalTrips).toBe('number');
expect(typeof stats.totalPlaces).toBe('number');
expect(typeof stats.totalFiles).toBe('number');
});
});
// ── getPermissions / savePermissions ─────────────────────────────────────────
describe('Permissions', () => {
it('ADMIN-SVC-019 — getPermissions returns an array of actions', () => {
const result = getPermissions() as any;
expect(Array.isArray(result.permissions)).toBe(true);
expect(result.permissions.length).toBeGreaterThan(0);
});
it('ADMIN-SVC-020 — savePermissions persists a permission change', () => {
savePermissions({ trip_create: 'admin' });
const result = getPermissions() as any;
const perm = result.permissions.find((p: any) => p.key === 'trip_create');
expect(perm.level).toBe('admin');
});
});
// ── getAuditLog ───────────────────────────────────────────────────────────────
describe('getAuditLog', () => {
it('ADMIN-SVC-021 — returns entries array with total', () => {
const result = getAuditLog({}) as any;
expect(Array.isArray(result.entries)).toBe(true);
expect(typeof result.total).toBe('number');
expect(result.limit).toBe(100);
expect(result.offset).toBe(0);
});
it('ADMIN-SVC-022 — respects limit and offset params', () => {
const result = getAuditLog({ limit: '10', offset: '0' }) as any;
expect(result.limit).toBe(10);
expect(result.offset).toBe(0);
});
it('ADMIN-SVC-023 — caps limit at 500', () => {
const result = getAuditLog({ limit: '9999' }) as any;
expect(result.limit).toBe(500);
});
});
// ── Invites ───────────────────────────────────────────────────────────────────
describe('Invites', () => {
it('ADMIN-SVC-024 — createInvite returns invite with token', () => {
const { user: admin } = createAdmin(testDb);
const result = createInvite(admin.id, { max_uses: 5 }) as any;
expect(result.invite.token).toBeDefined();
expect(result.invite.max_uses).toBe(5);
});
it('ADMIN-SVC-025 — createInvite defaults to 1 use', () => {
const { user: admin } = createAdmin(testDb);
const result = createInvite(admin.id, {}) as any;
expect(result.uses).toBe(1);
});
it('ADMIN-SVC-026 — listInvites returns array', () => {
const { user: admin } = createAdmin(testDb);
createInvite(admin.id, {});
const invites = listInvites() as any[];
expect(invites.length).toBeGreaterThanOrEqual(1);
});
it('ADMIN-SVC-027 — deleteInvite removes invite', () => {
const { user: admin } = createAdmin(testDb);
const invite = createInviteToken(testDb, { created_by: admin.id }) as any;
const result = deleteInvite(String(invite.id)) as any;
expect(result.error).toBeUndefined();
const check = testDb.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(invite.id);
expect(check).toBeUndefined();
});
it('ADMIN-SVC-028 — deleteInvite returns 404 for non-existent invite', () => {
const result = deleteInvite('99999') as any;
expect(result.status).toBe(404);
});
});
// ── Bag tracking ──────────────────────────────────────────────────────────────
describe('Bag tracking', () => {
it('ADMIN-SVC-029 — getBagTracking returns enabled state', () => {
const result = getBagTracking() as any;
expect(typeof result.enabled).toBe('boolean');
});
it('ADMIN-SVC-030 — updateBagTracking persists the value', () => {
updateBagTracking(true);
expect((getBagTracking() as any).enabled).toBe(true);
updateBagTracking(false);
expect((getBagTracking() as any).enabled).toBe(false);
});
});
// ── Packing templates ─────────────────────────────────────────────────────────
describe('Packing templates', () => {
it('ADMIN-SVC-031 — createPackingTemplate returns template', () => {
const { user: admin } = createAdmin(testDb);
const result = createPackingTemplate('Beach Trip', admin.id) as any;
expect(result.template.name).toBe('Beach Trip');
});
it('ADMIN-SVC-032 — createPackingTemplate returns 400 for empty name', () => {
const { user: admin } = createAdmin(testDb);
const result = createPackingTemplate('', admin.id) as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-033 — listPackingTemplates returns array', () => {
const { user: admin } = createAdmin(testDb);
createPackingTemplate('Template A', admin.id);
const templates = listPackingTemplates() as any[];
expect(templates.length).toBeGreaterThanOrEqual(1);
});
it('ADMIN-SVC-034 — updatePackingTemplate updates name', () => {
const { user: admin } = createAdmin(testDb);
const created = createPackingTemplate('Old Name', admin.id) as any;
const result = updatePackingTemplate(String(created.template.id), { name: 'New Name' }) as any;
expect(result.template.name).toBe('New Name');
});
it('ADMIN-SVC-035 — updatePackingTemplate returns 404 for non-existent', () => {
const result = updatePackingTemplate('99999', { name: 'Ghost' }) as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-036 — deletePackingTemplate removes template', () => {
const { user: admin } = createAdmin(testDb);
const created = createPackingTemplate('To Delete', admin.id) as any;
const result = deletePackingTemplate(String(created.template.id)) as any;
expect(result.name).toBe('To Delete');
});
it('ADMIN-SVC-037 — deletePackingTemplate returns 404 for non-existent', () => {
const result = deletePackingTemplate('99999') as any;
expect(result.status).toBe(404);
});
});
// ── Template categories ───────────────────────────────────────────────────────
describe('Template categories', () => {
it('ADMIN-SVC-038 — createTemplateCategory creates a category', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const result = createTemplateCategory(String(tpl.template.id), 'Clothing') as any;
expect(result.category.name).toBe('Clothing');
});
it('ADMIN-SVC-039 — createTemplateCategory returns 400 for empty name', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const result = createTemplateCategory(String(tpl.template.id), '') as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-040 — createTemplateCategory returns 404 for missing template', () => {
const result = createTemplateCategory('99999', 'Clothing') as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-041 — updateTemplateCategory updates name', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const cat = createTemplateCategory(String(tpl.template.id), 'Old') as any;
const result = updateTemplateCategory(String(tpl.template.id), String(cat.category.id), { name: 'New' }) as any;
expect(result.category.name).toBe('New');
});
it('ADMIN-SVC-042 — updateTemplateCategory returns 404 for missing category', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const result = updateTemplateCategory(String(tpl.template.id), '99999', { name: 'X' }) as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-043 — deleteTemplateCategory removes category', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const cat = createTemplateCategory(String(tpl.template.id), 'Remove Me') as any;
const result = deleteTemplateCategory(String(tpl.template.id), String(cat.category.id)) as any;
expect(result.error).toBeUndefined();
});
it('ADMIN-SVC-044 — deleteTemplateCategory returns 404 for missing', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const result = deleteTemplateCategory(String(tpl.template.id), '99999') as any;
expect(result.status).toBe(404);
});
});
// ── getAuditLog — JSON details parsing ───────────────────────────────────────
describe('getAuditLog — JSON details', () => {
it('ADMIN-SVC-045 — parses JSON details when present', () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
user.id, 'test_action', JSON.stringify({ key: 'val' })
);
const result = getAuditLog({}) as any;
expect(result.entries.length).toBeGreaterThanOrEqual(1);
const entry = result.entries.find((e: any) => e.action === 'test_action');
expect(entry).toBeDefined();
expect(entry.details).toEqual({ key: 'val' });
});
it('ADMIN-SVC-046 — handles invalid JSON gracefully with _parse_error flag', () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
user.id, 'bad_json_action', 'not-valid-json{'
);
const result = getAuditLog({}) as any;
const entry = result.entries.find((e: any) => e.action === 'bad_json_action');
expect(entry).toBeDefined();
expect(entry.details).toEqual({ _parse_error: true });
});
});
// ── OIDC Settings ─────────────────────────────────────────────────────────────
describe('OIDC Settings', () => {
it('ADMIN-SVC-047 — getOidcSettings returns default empty values when no OIDC configured', () => {
const result = getOidcSettings() as any;
expect(result.issuer).toBe('');
expect(result.client_id).toBe('');
expect(result.oidc_only).toBe(false);
expect(result.client_secret_set).toBe(false);
expect(result.display_name).toBe('');
expect(result.discovery_url).toBe('');
});
it('ADMIN-SVC-048 — updateOidcSettings persists issuer and client_id, then getOidcSettings returns them', () => {
updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' });
const result = getOidcSettings() as any;
expect(result.issuer).toBe('https://auth.example.com');
expect(result.client_id).toBe('my-client');
});
it('ADMIN-SVC-049 — updateOidcSettings sets oidc_only flag correctly', () => {
updateOidcSettings({ oidc_only: true });
const enabled = getOidcSettings() as any;
expect(enabled.oidc_only).toBe(true);
updateOidcSettings({ oidc_only: false });
const disabled = getOidcSettings() as any;
expect(disabled.oidc_only).toBe(false);
});
});
// ── saveDemoBaseline ──────────────────────────────────────────────────────────
describe('saveDemoBaseline', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('ADMIN-SVC-050 — returns 404 when DEMO_MODE is not "true"', () => {
vi.stubEnv('DEMO_MODE', 'false');
const result = saveDemoBaseline() as any;
expect(result.status).toBe(404);
expect(result.error).toBeDefined();
});
it('ADMIN-SVC-051 — returns a defined result object when DEMO_MODE is "true"', () => {
// saveDemoBaseline() uses a dynamic CJS require() whose mock cannot be
// intercepted via vi.mock in this test environment (tsx runtime + CJS loader).
// The function either succeeds (message) or falls through the catch to a
// 500 error. Either way the result must be a defined, non-null object.
vi.stubEnv('DEMO_MODE', 'true');
const result = saveDemoBaseline() as any;
expect(result).toBeDefined();
expect(typeof result).toBe('object');
// The 404 branch must NOT be taken — DEMO_MODE is "true".
expect(result.status).not.toBe(404);
});
});
// ── getGithubReleases ─────────────────────────────────────────────────────────
describe('getGithubReleases', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('ADMIN-SVC-052 — returns empty array when fetch fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
const result = await getGithubReleases();
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(0);
});
it('ADMIN-SVC-053 — returns releases array when fetch succeeds', async () => {
const mockReleases = [
{ id: 1, tag_name: 'v3.0.0', name: 'Release 3.0.0', html_url: 'https://github.com/example/releases/tag/v3.0.0' },
{ id: 2, tag_name: 'v2.9.9', name: 'Release 2.9.9', html_url: 'https://github.com/example/releases/tag/v2.9.9' },
];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => mockReleases,
}));
const result = await getGithubReleases();
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
expect((result as any[])[0].tag_name).toBe('v3.0.0');
});
});
// ── checkVersion ──────────────────────────────────────────────────────────────
describe('checkVersion', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('ADMIN-SVC-054 — returns update_available:false when fetch fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
const result = await checkVersion() as any;
expect(result.update_available).toBe(false);
expect(result.current).toBeDefined();
expect(result.latest).toBeDefined();
});
it('ADMIN-SVC-055 — returns update_available:true when latest version is greater than current', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ tag_name: 'v999.0.0', html_url: 'https://github.com/example/releases/tag/v999.0.0' }),
}));
const result = await checkVersion() as any;
expect(result.update_available).toBe(true);
expect(result.latest).toBe('999.0.0');
expect(result.release_url).toBe('https://github.com/example/releases/tag/v999.0.0');
});
});
// ── getPackingTemplate ────────────────────────────────────────────────────────
describe('getPackingTemplate', () => {
it('ADMIN-SVC-056 — returns template with categories and items when template exists', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Full Template', admin.id) as any;
const cat = createTemplateCategory(String(tpl.template.id), 'Clothing') as any;
createTemplateItem(String(tpl.template.id), String(cat.category.id), 'T-Shirt');
const result = getPackingTemplate(String(tpl.template.id)) as any;
expect(result.template).toBeDefined();
expect(result.template.name).toBe('Full Template');
expect(Array.isArray(result.categories)).toBe(true);
expect(result.categories.length).toBeGreaterThanOrEqual(1);
expect(Array.isArray(result.items)).toBe(true);
expect(result.items.length).toBeGreaterThanOrEqual(1);
expect(result.items[0].name).toBe('T-Shirt');
});
it('ADMIN-SVC-057 — returns 404 for non-existent template', () => {
const result = getPackingTemplate('99999') as any;
expect(result.status).toBe(404);
expect(result.error).toBeDefined();
});
});
// ── Template items ────────────────────────────────────────────────────────────
describe('Template items', () => {
it('ADMIN-SVC-058 — createTemplateItem returns item with name', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const cat = createTemplateCategory(String(tpl.template.id), 'Gear') as any;
const result = createTemplateItem(String(tpl.template.id), String(cat.category.id), 'Backpack') as any;
expect(result.item).toBeDefined();
expect(result.item.name).toBe('Backpack');
});
it('ADMIN-SVC-059 — createTemplateItem returns 400 for empty name', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const cat = createTemplateCategory(String(tpl.template.id), 'Gear') as any;
const result = createTemplateItem(String(tpl.template.id), String(cat.category.id), '') as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-060 — createTemplateItem returns 404 for non-existent category', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const result = createTemplateItem(String(tpl.template.id), '99999', 'Item') as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-061 — updateTemplateItem updates name', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const cat = createTemplateCategory(String(tpl.template.id), 'Gear') as any;
const item = createTemplateItem(String(tpl.template.id), String(cat.category.id), 'Old Item') as any;
const result = updateTemplateItem(String(item.item.id), { name: 'New Item' }) as any;
expect(result.item.name).toBe('New Item');
});
it('ADMIN-SVC-062 — updateTemplateItem returns 404 for non-existent item', () => {
const result = updateTemplateItem('99999', { name: 'Ghost' }) as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-063 — deleteTemplateItem removes item', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const cat = createTemplateCategory(String(tpl.template.id), 'Gear') as any;
const item = createTemplateItem(String(tpl.template.id), String(cat.category.id), 'To Delete') as any;
const result = deleteTemplateItem(String(item.item.id)) as any;
expect(result.error).toBeUndefined();
const check = testDb.prepare('SELECT id FROM packing_template_items WHERE id = ?').get(item.item.id);
expect(check).toBeUndefined();
});
it('ADMIN-SVC-064 — deleteTemplateItem returns 404 for non-existent item', () => {
const result = deleteTemplateItem('99999') as any;
expect(result.status).toBe(404);
});
});
// ── listAddons ────────────────────────────────────────────────────────────────
describe('listAddons', () => {
it('ADMIN-SVC-065 — listAddons returns array containing seeded addon entries', () => {
const result = listAddons() as any[];
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
const addonIds = result.map((a: any) => a.id);
expect(addonIds).toContain('packing');
expect(addonIds).toContain('budget');
});
});
// ── updateAddon ───────────────────────────────────────────────────────────────
describe('updateAddon', () => {
it('ADMIN-SVC-066 — updateAddon enables and disables a seeded addon', () => {
const disabled = updateAddon('mcp', { enabled: false }) as any;
expect(disabled.addon).toBeDefined();
expect(disabled.addon.enabled).toBe(false);
const enabled = updateAddon('mcp', { enabled: true }) as any;
expect(enabled.addon.enabled).toBe(true);
});
it('ADMIN-SVC-067 — updateAddon returns 404 for unknown addon id', () => {
const result = updateAddon('nonexistent-addon-xyz', { enabled: true }) as any;
expect(result.status).toBe(404);
expect(result.error).toBeDefined();
});
});
// ── MCP Tokens ────────────────────────────────────────────────────────────────
describe('MCP Tokens', () => {
it('ADMIN-SVC-068 — listMcpTokens returns empty array initially', () => {
const result = listMcpTokens() as any[];
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(0);
});
it('ADMIN-SVC-069 — deleteMcpToken returns 404 for non-existent token', () => {
const result = deleteMcpToken('99999') as any;
expect(result.status).toBe(404);
expect(result.error).toBeDefined();
});
});
@@ -0,0 +1,506 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup (real in-memory SQLite — same pattern as mcp unit tests) ────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { getStats, getCached, setCache, getCountryFromCoords, getCountryFromAddress, reverseGeocodeCountry, getRegionGeo, getCountryPlaces, getVisitedRegions } from '../../../src/services/atlasService';
function insertPlace(db: any, tripId: number, name: string, address: string | null = null) {
const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
const result = db.prepare(
'INSERT INTO places (trip_id, name, address, category_id) VALUES (?, ?, ?, ?)'
).run(tripId, name, address, cat?.id ?? null);
return db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid);
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
// Stub fetch so reverseGeocodeCountry never makes real HTTP calls
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: async () => ({}),
}));
});
afterAll(() => {
vi.unstubAllGlobals();
testDb.close();
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('getStats', () => {
it('ATLAS-UNIT-001: returns mostVisited null when trips have no resolvable countries (guards reduce on empty array)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Mystery Trip' });
// Place with no address and no coordinates → can't resolve country
insertPlace(testDb, trip.id, 'Unknown Place', null);
const stats = await getStats(user.id);
expect(stats.mostVisited).toBeNull();
expect(stats.countries).toEqual([]);
expect(stats.stats.totalPlaces).toBe(1);
expect(stats.stats.totalCountries).toBe(0);
});
it('ATLAS-UNIT-002: returns the country with the highest placeCount as mostVisited', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Euro Tour' });
// 3 places in France, 1 in Germany → France should win
for (let i = 0; i < 3; i++) {
insertPlace(testDb, trip.id, `Paris Place ${i}`, `Street ${i}, Paris, France`);
}
insertPlace(testDb, trip.id, 'Berlin Place', 'Some Street, Berlin, Germany');
const stats = await getStats(user.id);
expect(stats.mostVisited).not.toBeNull();
expect(stats.mostVisited!.code).toBe('FR');
expect(stats.mostVisited!.placeCount).toBe(3);
expect(stats.countries).toHaveLength(2);
expect(stats.stats.totalCountries).toBe(2);
});
it('ATLAS-UNIT-003: returns manually marked countries when user has no trips', async () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'AU');
const stats = await getStats(user.id);
expect(stats.countries).toHaveLength(2);
expect(stats.countries.map((c: { code: string }) => c.code).sort()).toEqual(['AU', 'JP']);
expect(stats.stats.totalTrips).toBe(0);
expect(stats.stats.totalCountries).toBe(2);
});
it('ATLAS-UNIT-004: single country yields mostVisited equal to that country', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Italy Trip' });
insertPlace(testDb, trip.id, 'Colosseum', 'Piazza del Colosseo, Rome, Italy');
const stats = await getStats(user.id);
expect(stats.mostVisited).not.toBeNull();
expect(stats.mostVisited!.code).toBe('IT');
expect(stats.mostVisited!.placeCount).toBe(1);
});
});
// ── getCached / setCache ────────────────────────────────────────────────────
describe('getCached and setCache', () => {
it('ATLAS-SVC-001: getCached returns undefined for unknown coordinates', () => {
// Use uniquely large lat values to guarantee no prior cache entry
const result = getCached(9001.001, 9001.001);
expect(result).toBeUndefined();
});
it('ATLAS-SVC-002: setCache then getCached returns the stored code', () => {
setCache(9002.002, 9002.002, 'DE');
const result = getCached(9002.002, 9002.002);
expect(result).toBe('DE');
});
it('ATLAS-SVC-003: setCache can store null (country unknown)', () => {
setCache(9003.003, 9003.003, null);
const result = getCached(9003.003, 9003.003);
expect(result).toBeNull();
});
it('ATLAS-SVC-004: different coordinates return different cached values', () => {
setCache(9004.004, 9004.004, 'FR');
setCache(9004.005, 9004.005, 'ES');
expect(getCached(9004.004, 9004.004)).toBe('FR');
expect(getCached(9004.005, 9004.005)).toBe('ES');
});
});
// ── getCountryFromCoords ────────────────────────────────────────────────────
describe('getCountryFromCoords', () => {
it('ATLAS-SVC-005: returns country code for Paris coordinates (France)', () => {
// Paris: approximately 48.85°N, 2.35°E — well inside FR bounding box
const code = getCountryFromCoords(48.85, 2.35);
expect(code).toBe('FR');
});
it('ATLAS-SVC-006: returns country code for NYC coordinates (USA)', () => {
// New York City: approximately 40.71°N, -74.0°W — inside US bounding box
const code = getCountryFromCoords(40.71, -74.0);
expect(code).toBe('US');
});
it('ATLAS-SVC-007: returns null for coordinates with no country match (0,0)', () => {
// Gulf of Guinea — no COUNTRY_BOXES entry covers 0°N, 0°E
const code = getCountryFromCoords(0.0, 0.0);
expect(code).toBeNull();
});
});
// ── getCountryFromAddress ───────────────────────────────────────────────────
describe('getCountryFromAddress', () => {
it('ATLAS-SVC-008: returns null for null address', () => {
expect(getCountryFromAddress(null)).toBeNull();
});
it('ATLAS-SVC-009: returns null for empty string', () => {
expect(getCountryFromAddress('')).toBeNull();
});
it('ATLAS-SVC-010: parses "France" in last position to "FR"', () => {
expect(getCountryFromAddress('Eiffel Tower, Paris, France')).toBe('FR');
});
it('ATLAS-SVC-011: returns 2-letter ISO code directly when last part is uppercase 2-letter', () => {
// "US" is uppercase and exactly 2 characters — returned verbatim
expect(getCountryFromAddress('123 Main St, New York, US')).toBe('US');
});
it('ATLAS-SVC-012: returns null for unrecognized country name', () => {
expect(getCountryFromAddress('Unknown City, Unknown Country')).toBeNull();
});
});
// ── reverseGeocodeCountry ───────────────────────────────────────────────────
describe('reverseGeocodeCountry', () => {
it('ATLAS-SVC-013: returns null when fetch fails (ok:false)', async () => {
// The beforeEach stub already returns ok:false — this is the default path
const code = await reverseGeocodeCountry(9013.013, 9013.013);
expect(code).toBeNull();
});
it('ATLAS-SVC-014: returns country code when Nominatim returns valid response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ address: { country_code: 'fr' } }),
}));
// Berlin-ish coords not used elsewhere — unique to avoid cache collision
const code = await reverseGeocodeCountry(52.52, 13.40);
expect(code).toBe('FR');
});
it('ATLAS-SVC-015: returns null when fetch throws a network error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
const code = await reverseGeocodeCountry(9015.015, 9015.015);
expect(code).toBeNull();
});
it('ATLAS-SVC-016: returns cached result on second call (fetch called only once)', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ address: { country_code: 'gb' } }),
});
vi.stubGlobal('fetch', mockFetch);
// Use unique coords so neither call hits a prior cache entry
const first = await reverseGeocodeCountry(9016.016, 9016.016);
const second = await reverseGeocodeCountry(9016.016, 9016.016);
expect(first).toBe('GB');
expect(second).toBe('GB');
// fetch should have been invoked only once; the second call uses the in-memory cache
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
// ── getRegionGeo ────────────────────────────────────────────────────────────
describe('getRegionGeo', () => {
it('ATLAS-SVC-017: returns empty FeatureCollection when fetch throws a network error', async () => {
// Override the default stub to throw so loadAdmin1Geo's .catch handler runs,
// returning null — which causes getRegionGeo to return the empty FeatureCollection.
// (The default ok:false stub does NOT trigger the catch; it still resolves json()
// to {}, which loadAdmin1Geo caches as a non-null truthy value.)
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network failure')));
const result = await getRegionGeo(['DE', 'FR']);
expect(result).toEqual({ type: 'FeatureCollection', features: [] });
});
it('ATLAS-SVC-018: returns filtered features for matching country codes when fetch returns mock GeoJSON', async () => {
// ATLAS-SVC-017 ran with a throwing fetch, so admin1GeoCache is null and
// admin1GeoLoading is null — this test's fetch override will be called.
const mockGeoJson = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { iso_a2: 'DE' }, geometry: {} },
{ type: 'Feature', properties: { iso_a2: 'FR' }, geometry: {} },
],
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => mockGeoJson,
}));
// Pass lowercase 'de' — getRegionGeo uppercases internally for matching
const result = await getRegionGeo(['de']);
expect(result.type).toBe('FeatureCollection');
expect(result.features).toHaveLength(1);
expect(result.features[0].properties.iso_a2).toBe('DE');
});
});
// ── Helpers for new tests ────────────────────────────────────────────────────
function insertPlaceWithCoords(db: any, tripId: number, name: string, lat: number, lng: number, address: string | null = null) {
const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
const result = db.prepare(
'INSERT INTO places (trip_id, name, address, lat, lng, category_id) VALUES (?, ?, ?, ?, ?, ?)'
).run(tripId, name, address, lat, lng, cat?.id ?? null);
return db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid);
}
// ── getStats — extended ──────────────────────────────────────────────────────
describe('getStats — extended', () => {
it('ATLAS-UNIT-005: totalDays is calculated when trip has start_date and end_date', async () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'Short Trip', start_date: '2024-03-01', end_date: '2024-03-03' });
const stats = await getStats(user.id);
// March 1, 2, 3 → diff = 2 + 1 = 3
expect(stats.stats.totalDays).toBe(3);
});
it('ATLAS-UNIT-006: totalDays is 0 when trip has no dates', async () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'Dateless' });
const stats = await getStats(user.id);
expect(stats.stats.totalDays).toBe(0);
});
it('ATLAS-UNIT-007: manually marked country is merged when user has trips but no resolvable places for that country', async () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'Japan Trip', start_date: '2024-01-01', end_date: '2024-01-10' });
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
const stats = await getStats(user.id);
const codes = stats.countries.map((c: any) => c.code);
expect(codes).toContain('JP');
const jp = stats.countries.find((c: any) => c.code === 'JP');
expect(jp?.placeCount).toBe(0);
});
it('ATLAS-UNIT-008: lastTrip is resolved with a country code when its places have an address', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Past France Trip', start_date: '2023-05-01', end_date: '2023-05-10' });
insertPlace(testDb, trip.id, 'Eiffel Tower', 'Champ de Mars, Paris, France');
const stats = await getStats(user.id);
expect(stats.lastTrip).not.toBeNull();
expect(stats.lastTrip!.countryCode).toBe('FR');
});
it('ATLAS-UNIT-009: nextTrip has daysUntil calculated', async () => {
const { user } = createUser(testDb);
const futureDate = new Date();
futureDate.setFullYear(futureDate.getFullYear() + 1);
const futureDateStr = futureDate.toISOString().split('T')[0];
createTrip(testDb, user.id, { title: 'Future Trip', start_date: futureDateStr });
const stats = await getStats(user.id);
expect(stats.nextTrip).not.toBeNull();
expect(stats.nextTrip!.daysUntil).toBeGreaterThan(0);
});
it('ATLAS-UNIT-010: streak counts consecutive years with trips and firstYear is the earliest', async () => {
const { user } = createUser(testDb);
const currentYear = new Date().getFullYear();
createTrip(testDb, user.id, { title: 'This Year', start_date: `${currentYear}-06-01`, end_date: `${currentYear}-06-10` });
createTrip(testDb, user.id, { title: 'Last Year', start_date: `${currentYear - 1}-07-01`, end_date: `${currentYear - 1}-07-10` });
const stats = await getStats(user.id);
expect(stats.streak).toBeGreaterThanOrEqual(1);
expect(stats.firstYear).toBe(currentYear - 1);
});
it('ATLAS-UNIT-011: tripsThisYear counts only trips whose start_date is in the current year', async () => {
const { user } = createUser(testDb);
const currentYear = new Date().getFullYear();
createTrip(testDb, user.id, { title: 'This Year', start_date: `${currentYear}-03-01` });
createTrip(testDb, user.id, { title: 'Last Year', start_date: `${currentYear - 1}-03-01` });
const stats = await getStats(user.id);
expect(stats.tripsThisYear).toBe(1);
});
it('ATLAS-UNIT-012: lastTrip is null when all trips end in the future', async () => {
const { user } = createUser(testDb);
const nextYear = new Date().getFullYear() + 1;
createTrip(testDb, user.id, { title: 'Future', start_date: `${nextYear}-01-01`, end_date: `${nextYear}-01-10` });
const stats = await getStats(user.id);
expect(stats.lastTrip).toBeNull();
});
});
// ── getCountryPlaces ─────────────────────────────────────────────────────────
describe('getCountryPlaces', () => {
it('ATLAS-UNIT-013: returns empty result when user has no trips', () => {
const { user } = createUser(testDb);
const result = getCountryPlaces(user.id, 'FR');
expect(result.places).toHaveLength(0);
expect(result.trips).toHaveLength(0);
expect(result.manually_marked).toBe(false);
});
it('ATLAS-UNIT-014: returns matching places when place address resolves to the requested country', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'France Trip' });
insertPlace(testDb, trip.id, 'Louvre', '75001 Paris, France');
insertPlace(testDb, trip.id, 'Berlin Wall', 'Bernauer Str., Berlin, Germany');
const result = getCountryPlaces(user.id, 'FR');
expect(result.places).toHaveLength(1);
expect(result.places[0].name).toBe('Louvre');
expect(result.trips).toHaveLength(1);
expect(result.trips[0].id).toBe(trip.id);
});
it('ATLAS-UNIT-015: manually_marked is true when country is in visited_countries', () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
createTrip(testDb, user.id, { title: 'Japan' });
const result = getCountryPlaces(user.id, 'JP');
expect(result.manually_marked).toBe(true);
});
it('ATLAS-UNIT-016: place with coordinates resolves via bbox when address is absent', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Coord Trip' });
// Paris coordinates (48.85°N, 2.35°E) — falls inside FR bounding box
insertPlaceWithCoords(testDb, trip.id, 'Secret Paris Spot', 48.85, 2.35);
const result = getCountryPlaces(user.id, 'FR');
expect(result.places).toHaveLength(1);
expect(result.places[0].name).toBe('Secret Paris Spot');
});
});
// ── getVisitedRegions ────────────────────────────────────────────────────────
describe('getVisitedRegions', () => {
it('ATLAS-UNIT-017: returns empty regions object when user has no trips', async () => {
const { user } = createUser(testDb);
const result = await getVisitedRegions(user.id);
expect(result.regions).toEqual({});
});
it('ATLAS-UNIT-018: returns manually marked regions even when user has no places with coordinates', async () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'DE');
testDb.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)').run(user.id, 'DE-BY', 'Bayern', 'DE');
const result = await getVisitedRegions(user.id);
expect(result.regions['DE']).toBeDefined();
const codes = result.regions['DE'].map((r: any) => r.code);
expect(codes).toContain('DE-BY');
const bayernRegion = result.regions['DE'].find((r: any) => r.code === 'DE-BY');
expect(bayernRegion?.manuallyMarked).toBe(true);
});
it('ATLAS-UNIT-019: geocodes places with lat/lng using reverseGeocodeRegion via fetch', async () => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
address: {
country_code: 'fr',
'ISO3166-2-lvl4': 'FR-75',
state: 'Île-de-France',
},
}),
}));
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
insertPlaceWithCoords(testDb, trip.id, 'Paris Hotel', 48.85, 2.35);
const resultPromise = getVisitedRegions(user.id);
// Advance all pending timers (including the 1100ms Nominatim rate-limit delay)
await vi.runAllTimersAsync();
const result = await resultPromise;
expect(result.regions['FR']).toBeDefined();
vi.useRealTimers();
});
it('ATLAS-UNIT-020: places already cached in place_regions are not re-geocoded', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Cached Trip' });
const place = insertPlaceWithCoords(testDb, trip.id, 'Cached Place', 48.85, 2.35);
// Pre-populate the place_regions cache so the fetch path is never reached
testDb.prepare(
'INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)'
).run(place.id, 'FR', 'FR-75', 'Île-de-France');
const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
vi.stubGlobal('fetch', mockFetch);
const result = await getVisitedRegions(user.id);
expect(mockFetch).not.toHaveBeenCalled();
expect(result.regions['FR']).toBeDefined();
const codes = result.regions['FR'].map((r: any) => r.code);
expect(codes).toContain('FR-75');
});
});
@@ -0,0 +1,596 @@
/**
* authServiceDb.test.ts
*
* DB-centric unit tests for authService.ts using a real in-memory SQLite database.
* Pure function tests live in authService.test.ts (stub DB); this file covers
* functions that require actual DB queries to exercise their logic.
*/
// ---------------------------------------------------------------------------
// vi.hoisted: build the real in-memory DB and the module mock before any import
// ---------------------------------------------------------------------------
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
canAccessTrip: (tripId: any, userId: number) =>
db
.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/services/mfaCrypto', () => ({
encryptMfaSecret: vi.fn((s) => `enc:${s}`),
decryptMfaSecret: vi.fn((s: string) => s.replace('enc:', '')),
}));
vi.mock('../../../src/services/apiKeyCrypto', () => ({
decrypt_api_key: vi.fn((v) => v),
maybe_encrypt_api_key: vi.fn((v) => v),
mask_stored_api_key: vi.fn((v: string | null | undefined) => (v ? '••••••••' : null)),
encrypt_api_key: vi.fn((v) => v),
}));
vi.mock('../../../src/services/permissions', () => ({
getAllPermissions: vi.fn(() => ({})),
checkPermission: vi.fn(),
}));
vi.mock('../../../src/services/ephemeralTokens', () => ({ createEphemeralToken: vi.fn() }));
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() }));
vi.mock('../../../src/scheduler', () => ({
startTripReminders: vi.fn(),
buildCronExpression: vi.fn(),
loadSettings: vi.fn(() => ({ enabled: false })),
VALID_INTERVALS: ['daily', 'weekly', 'monthly'],
}));
// ---------------------------------------------------------------------------
// Imports (after mocks)
// ---------------------------------------------------------------------------
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, createInviteToken } from '../../helpers/factories';
import {
updateSettings,
getSettings,
listUsers,
getAppSettings,
validateKeys,
isOidcOnlyMode,
setupMfa,
enableMfa,
disableMfa,
validateInviteToken,
registerUser,
loginUser,
changePassword,
verifyMfaLogin,
createMcpToken,
deleteMcpToken,
} from '../../../src/services/authService';
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => resetTestDb(testDb));
afterAll(() => testDb.close());
// ---------------------------------------------------------------------------
// updateSettings
// ---------------------------------------------------------------------------
describe('updateSettings', () => {
it('AUTH-DB-001: updates username successfully', () => {
const { user } = createUser(testDb);
const result = updateSettings(user.id, { username: 'newname' });
expect(result.success).toBe(true);
expect(result.user?.username).toBe('newname');
});
it('AUTH-DB-002: returns 400 when username is too short (< 2 chars)', () => {
const { user } = createUser(testDb);
const result = updateSettings(user.id, { username: 'x' });
expect(result.status).toBe(400);
expect(result.error).toMatch(/between 2 and 50/i);
});
it('AUTH-DB-003: returns 400 when username has invalid characters (spaces)', () => {
const { user } = createUser(testDb);
const result = updateSettings(user.id, { username: 'bad name' });
expect(result.status).toBe(400);
expect(result.error).toMatch(/only contain/i);
});
it('AUTH-DB-004: returns 409 when username is already taken by another user', () => {
const { user: user1 } = createUser(testDb, { username: 'alice' });
const { user: user2 } = createUser(testDb, { username: 'bob' });
const result = updateSettings(user2.id, { username: user1.username });
expect(result.status).toBe(409);
expect(result.error).toMatch(/already taken/i);
});
it('AUTH-DB-005: updates email successfully', () => {
const { user } = createUser(testDb);
const result = updateSettings(user.id, { email: 'new@example.com' });
expect(result.success).toBe(true);
expect(result.user?.email).toBe('new@example.com');
});
it('AUTH-DB-006: returns 400 for invalid email format', () => {
const { user } = createUser(testDb);
const result = updateSettings(user.id, { email: 'not-an-email' });
expect(result.status).toBe(400);
expect(result.error).toMatch(/invalid email/i);
});
it('AUTH-DB-007: returns 409 when email is already taken by another user', () => {
const { user: user1 } = createUser(testDb, { email: 'taken@example.com' });
const { user: user2 } = createUser(testDb);
const result = updateSettings(user2.id, { email: user1.email });
expect(result.status).toBe(409);
expect(result.error).toMatch(/already taken/i);
});
it('AUTH-DB-008: returns success with no field changes when empty body is passed', () => {
const { user } = createUser(testDb);
const result = updateSettings(user.id, {});
expect(result.success).toBe(true);
});
});
// ---------------------------------------------------------------------------
// getSettings
// ---------------------------------------------------------------------------
describe('getSettings', () => {
it('AUTH-DB-009: returns 403 for non-admin user', () => {
const { user } = createUser(testDb);
const result = getSettings(user.id);
expect(result.status).toBe(403);
expect(result.error).toMatch(/admin/i);
});
it('AUTH-DB-010: returns maps_api_key and openweather_api_key for admin', () => {
const { user } = createAdmin(testDb);
testDb
.prepare('UPDATE users SET maps_api_key = ?, openweather_api_key = ? WHERE id = ?')
.run('maps-key-value', 'weather-key-value', user.id);
const result = getSettings(user.id);
expect(result.status).toBeUndefined();
expect(result.settings).toBeDefined();
expect(result.settings).toHaveProperty('maps_api_key');
expect(result.settings).toHaveProperty('openweather_api_key');
});
});
// ---------------------------------------------------------------------------
// listUsers
// ---------------------------------------------------------------------------
describe('listUsers', () => {
it('AUTH-DB-011: returns all users except self, sorted by username', () => {
const { user: self } = createUser(testDb, { username: 'zzself' });
createUser(testDb, { username: 'alice' });
createUser(testDb, { username: 'charlie' });
createUser(testDb, { username: 'bob' });
const result = listUsers(self.id);
expect(result).toHaveLength(3);
const names = result.map((u) => u.username);
expect(names).toEqual([...names].sort());
expect(names).not.toContain('zzself');
});
it('AUTH-DB-012: returns empty array when only one user exists', () => {
const { user } = createUser(testDb);
const result = listUsers(user.id);
expect(result).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// getAppSettings
// ---------------------------------------------------------------------------
describe('getAppSettings', () => {
it('AUTH-DB-013: returns 403 for non-admin', () => {
const { user } = createUser(testDb);
const result = getAppSettings(user.id);
expect(result.status).toBe(403);
expect(result.error).toMatch(/admin/i);
});
it('AUTH-DB-014: returns settings object for admin with known key allow_registration', () => {
const { user } = createAdmin(testDb);
testDb
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'true')")
.run();
const result = getAppSettings(user.id);
expect(result.status).toBeUndefined();
expect(result.data).toBeDefined();
expect(result.data).toHaveProperty('allow_registration', 'true');
});
});
// ---------------------------------------------------------------------------
// validateKeys
// ---------------------------------------------------------------------------
describe('validateKeys', () => {
it('AUTH-DB-015: returns 403 for non-admin', async () => {
const { user } = createUser(testDb);
const result = await validateKeys(user.id);
expect(result.status).toBe(403);
expect(result.error).toMatch(/admin/i);
expect(result.maps).toBe(false);
expect(result.weather).toBe(false);
});
it('AUTH-DB-016: returns { maps: false, weather: false } when no API keys are stored', async () => {
const { user } = createAdmin(testDb);
const result = await validateKeys(user.id);
expect(result.maps).toBe(false);
expect(result.weather).toBe(false);
expect(result.maps_details).toBeNull();
});
it('AUTH-DB-017: returns { maps: true } when fetch returns 200', async () => {
const { user } = createAdmin(testDb);
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id);
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({
status: 200,
statusText: 'OK',
text: async () => '',
} as Response);
const result = await validateKeys(user.id);
expect(result.maps).toBe(true);
expect(result.maps_details?.ok).toBe(true);
fetchSpy.mockRestore();
});
it('AUTH-DB-018: returns { maps: false } when fetch throws a network error', async () => {
const { user } = createAdmin(testDb);
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id);
const fetchSpy = vi
.spyOn(global, 'fetch')
.mockRejectedValueOnce(new Error('Network failure'));
const result = await validateKeys(user.id);
expect(result.maps).toBe(false);
expect(result.maps_details?.error_status).toBe('FETCH_ERROR');
expect(result.maps_details?.error_message).toBe('Network failure');
fetchSpy.mockRestore();
});
});
// ---------------------------------------------------------------------------
// isOidcOnlyMode
// ---------------------------------------------------------------------------
describe('isOidcOnlyMode', () => {
it('AUTH-DB-019: returns false when OIDC_ONLY env var is not set', () => {
vi.stubEnv('OIDC_ONLY', '');
expect(isOidcOnlyMode()).toBe(false);
vi.unstubAllEnvs();
});
it('AUTH-DB-020: returns false when OIDC_ONLY=true but no OIDC_ISSUER configured', () => {
vi.stubEnv('OIDC_ONLY', 'true');
vi.stubEnv('OIDC_ISSUER', '');
vi.stubEnv('OIDC_CLIENT_ID', '');
expect(isOidcOnlyMode()).toBe(false);
vi.unstubAllEnvs();
});
it('AUTH-DB-021: returns true when OIDC_ONLY=true AND OIDC_ISSUER AND OIDC_CLIENT_ID are set', () => {
vi.stubEnv('OIDC_ONLY', 'true');
vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com');
vi.stubEnv('OIDC_CLIENT_ID', 'trek-client');
expect(isOidcOnlyMode()).toBe(true);
vi.unstubAllEnvs();
});
});
// ---------------------------------------------------------------------------
// setupMfa
// ---------------------------------------------------------------------------
describe('setupMfa', () => {
it('AUTH-DB-022: returns 403 in demo mode for demo@nomad.app', () => {
vi.stubEnv('DEMO_MODE', 'true');
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const result = setupMfa(user.id, 'demo@nomad.app');
expect(result.status).toBe(403);
expect(result.error).toMatch(/demo mode/i);
vi.unstubAllEnvs();
});
it('AUTH-DB-023: returns 400 when MFA is already enabled', () => {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id);
const result = setupMfa(user.id, user.email);
expect(result.status).toBe(400);
expect(result.error).toMatch(/already enabled/i);
});
it('AUTH-DB-024: returns secret and otpauth_url when MFA setup starts successfully', () => {
const { user } = createUser(testDb);
const result = setupMfa(user.id, user.email);
expect(result.error).toBeUndefined();
expect(typeof result.secret).toBe('string');
expect(result.secret!.length).toBeGreaterThan(0);
expect(typeof result.otpauth_url).toBe('string');
expect(result.otpauth_url).toMatch(/^otpauth:\/\/totp\//);
expect(result.qrPromise).toBeInstanceOf(Promise);
});
});
// ---------------------------------------------------------------------------
// enableMfa
// ---------------------------------------------------------------------------
describe('enableMfa', () => {
it('AUTH-DB-025: returns 400 when no verification code is provided', () => {
const { user } = createUser(testDb);
const result = enableMfa(user.id, undefined);
expect(result.status).toBe(400);
expect(result.error).toMatch(/code is required/i);
});
it('AUTH-DB-026: returns 400 when there is no pending MFA setup', () => {
const { user } = createUser(testDb);
// No setupMfa called first, so no pending entry exists
const result = enableMfa(user.id, '123456');
expect(result.status).toBe(400);
expect(result.error).toMatch(/no mfa setup in progress/i);
});
});
// ---------------------------------------------------------------------------
// disableMfa
// ---------------------------------------------------------------------------
describe('disableMfa', () => {
it('AUTH-DB-027: returns 403 in demo mode for demo@nomad.app', () => {
vi.stubEnv('DEMO_MODE', 'true');
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const result = disableMfa(user.id, 'demo@nomad.app', {
password: 'password123',
code: '000000',
});
expect(result.status).toBe(403);
expect(result.error).toMatch(/demo mode/i);
vi.unstubAllEnvs();
});
it('AUTH-DB-028: returns 400 when password or code is missing', () => {
const { user } = createUser(testDb);
const missingCode = disableMfa(user.id, user.email, { password: 'pass', code: undefined });
expect(missingCode.status).toBe(400);
expect(missingCode.error).toMatch(/password and authenticator code/i);
const missingPassword = disableMfa(user.id, user.email, { password: undefined, code: '123456' });
expect(missingPassword.status).toBe(400);
expect(missingPassword.error).toMatch(/password and authenticator code/i);
});
it('AUTH-DB-029: returns 400 when MFA is not enabled on the account', () => {
const { user } = createUser(testDb);
// mfa_enabled defaults to 0 / not set
const result = disableMfa(user.id, user.email, { password: 'password123', code: '000000' });
expect(result.status).toBe(400);
expect(result.error).toMatch(/not enabled/i);
});
});
// ---------------------------------------------------------------------------
// validateInviteToken
// ---------------------------------------------------------------------------
describe('validateInviteToken', () => {
it('AUTH-DB-030: returns 404 for unknown token', () => {
const result = validateInviteToken('no-such-token');
expect(result.status).toBe(404);
});
it('AUTH-DB-031: returns 410 when max_uses exceeded', () => {
// createInviteToken with used_count already at max
const invite = createInviteToken(testDb, { max_uses: 1 });
// manually set used_count = 1 to simulate exhaustion
testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id);
const result = validateInviteToken(invite.token);
expect(result.status).toBe(410);
});
it('AUTH-DB-032: returns 410 when expired', () => {
const invite = createInviteToken(testDb, { expires_at: '2000-01-01T00:00:00.000Z' });
const result = validateInviteToken(invite.token);
expect(result.status).toBe(410);
});
});
// ---------------------------------------------------------------------------
// registerUser — OIDC-only / registration-disabled
// ---------------------------------------------------------------------------
describe('registerUser — OIDC-only / registration-disabled', () => {
it('AUTH-DB-033: returns 403 when oidc_only=true and not first user', () => {
createUser(testDb); // ensure userCount > 0
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
const result = registerUser({ username: 'u', email: 'new@x.com', password: 'Secure123!' });
expect(result.status).toBe(403);
expect(result.error).toMatch(/SSO/i);
});
it('AUTH-DB-034: returns 403 when registration is disabled and no invite', () => {
createUser(testDb); // ensure userCount > 0
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
const result = registerUser({ username: 'u2', email: 'n2@x.com', password: 'Secure123!' });
expect(result.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// loginUser — OIDC-only mode
// ---------------------------------------------------------------------------
describe('loginUser — OIDC-only mode', () => {
it('AUTH-DB-035: returns 403 when oidc_only=true', () => {
const { user, password } = createUser(testDb);
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
const result = loginUser({ email: user.email, password });
expect(result.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// changePassword — OIDC-only mode
// ---------------------------------------------------------------------------
describe('changePassword — OIDC-only mode', () => {
it('AUTH-DB-036: returns 403 when oidc_only=true', () => {
const { user, password } = createUser(testDb);
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
const result = changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
expect(result.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// disableMfa — require_mfa policy
// ---------------------------------------------------------------------------
describe('disableMfa — require_mfa policy', () => {
it('AUTH-DB-037: returns 403 when require_mfa=true is set globally', () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
const result = disableMfa(user.id, user.email, { password: 'pass', code: '123456' });
expect(result.status).toBe(403);
expect(result.error).toMatch(/cannot be disabled/i);
});
});
// ---------------------------------------------------------------------------
// verifyMfaLogin — validation
// ---------------------------------------------------------------------------
describe('verifyMfaLogin — validation', () => {
it('AUTH-DB-038: returns 400 when mfa_token or code is missing', () => {
const result = verifyMfaLogin({ mfa_token: undefined, code: undefined });
expect(result.status).toBe(400);
});
it('AUTH-DB-039: returns 401 when mfa_token has wrong purpose', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const jwt = require('jsonwebtoken');
const tok = jwt.sign({ id: 1, purpose: 'wrong' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' });
const result = verifyMfaLogin({ mfa_token: tok, code: '123456' });
expect(result.status).toBe(401);
expect(result.error).toMatch(/invalid/i);
});
it('AUTH-DB-040: returns 401 when user not found for valid mfa_token', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const jwt = require('jsonwebtoken');
const tok = jwt.sign({ id: 99999, purpose: 'mfa_login' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' });
const result = verifyMfaLogin({ mfa_token: tok, code: '123456' });
expect(result.status).toBe(401);
});
});
// ---------------------------------------------------------------------------
// MCP token service
// ---------------------------------------------------------------------------
describe('MCP token service', () => {
it('AUTH-DB-041: createMcpToken returns 400 when name is missing', () => {
const { user } = createUser(testDb);
const result = createMcpToken(user.id, undefined);
expect(result.status).toBe(400);
});
it('AUTH-DB-042: createMcpToken returns 400 when name exceeds 100 chars', () => {
const { user } = createUser(testDb);
const result = createMcpToken(user.id, 'a'.repeat(101));
expect(result.status).toBe(400);
});
it('AUTH-DB-043: createMcpToken creates token and returns raw_token', () => {
const { user } = createUser(testDb);
const result = createMcpToken(user.id, 'My Token');
expect(result.token).toBeDefined();
expect((result.token as any).raw_token).toMatch(/^trek_/);
});
it('AUTH-DB-044: createMcpToken returns 400 when user has 10 tokens already', () => {
const { user } = createUser(testDb);
for (let i = 0; i < 10; i++) {
testDb.prepare(
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)'
).run(user.id, `Token ${i}`, `hash${i}`, `trek_prefix${i}`);
}
const result = createMcpToken(user.id, 'One More');
expect(result.status).toBe(400);
});
it('AUTH-DB-045: deleteMcpToken returns 404 for non-existent token', () => {
const { user } = createUser(testDb);
const result = deleteMcpToken(user.id, '99999');
expect(result.status).toBe(404);
});
it('AUTH-DB-046: deleteMcpToken deletes the token and returns success', () => {
const { user } = createUser(testDb);
const created = createMcpToken(user.id, 'Deletable Token');
const tokenId = String((created.token as any).id);
const result = deleteMcpToken(user.id, tokenId);
expect(result).toEqual({ success: true });
const row = testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId);
expect(row).toBeUndefined();
});
});
@@ -0,0 +1,932 @@
/**
* Unit tests for backupService.
* Covers BACKUP-031 to BACKUP-060.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ---------------------------------------------------------------------------
// Hoisted mocks — must be defined before any vi.mock() calls
// ---------------------------------------------------------------------------
const fsMock = vi.hoisted(() => ({
existsSync: vi.fn(),
mkdirSync: vi.fn(),
createWriteStream: vi.fn(),
unlinkSync: vi.fn(),
statSync: vi.fn(),
readdirSync: vi.fn(),
createReadStream: vi.fn(),
rmSync: vi.fn(),
copyFileSync: vi.fn(),
cpSync: vi.fn(),
}));
const archiverInstanceMock = vi.hoisted(() => ({
pipe: vi.fn(),
file: vi.fn(),
directory: vi.fn(),
finalize: vi.fn(),
on: vi.fn(),
}));
const archiverMock = vi.hoisted(() => vi.fn());
const unzipperMock = vi.hoisted(() => ({
Extract: vi.fn(),
}));
const dbMock = vi.hoisted(() => ({
db: {
exec: vi.fn(),
prepare: vi.fn(),
},
closeDb: vi.fn(),
reinitialize: vi.fn(),
getPlaceWithTags: vi.fn(),
canAccessTrip: vi.fn(),
isOwner: vi.fn(),
}));
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a'.repeat(64),
updateJwtSecret: () => {},
}));
vi.mock('fs', () => ({ default: fsMock, ...fsMock }));
vi.mock('archiver', () => ({ default: archiverMock }));
vi.mock('unzipper', () => ({ default: unzipperMock }));
vi.mock('../../../src/scheduler', () => ({
VALID_INTERVALS: ['hourly', 'daily', 'weekly', 'monthly'],
loadSettings: vi.fn(() => ({
enabled: false,
interval: 'daily',
keep_days: 7,
hour: 2,
day_of_week: 0,
day_of_month: 1,
})),
saveSettings: vi.fn(),
start: vi.fn(),
}));
import {
formatSize,
parseIntField,
parseAutoBackupBody,
isValidBackupFilename,
checkRateLimit,
createBackup,
deleteBackup,
restoreFromZip,
BACKUP_RATE_WINDOW,
backupFilePath,
backupFileExists,
listBackups,
updateAutoSettings,
} from '../../../src/services/backupService';
// ---------------------------------------------------------------------------
// formatSize
// ---------------------------------------------------------------------------
describe('BACKUP-031 formatSize', () => {
it('formats bytes < 1024 as B', () => {
expect(formatSize(500)).toBe('500 B');
});
it('formats bytes in KB range', () => {
expect(formatSize(1024)).toBe('1.0 KB');
expect(formatSize(2048)).toBe('2.0 KB');
});
it('formats bytes in MB range', () => {
expect(formatSize(1024 * 1024)).toBe('1.0 MB');
expect(formatSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
});
it('boundary: exactly 1024 bytes is 1.0 KB', () => {
expect(formatSize(1023)).toBe('1023 B');
expect(formatSize(1024)).toBe('1.0 KB');
});
});
// ---------------------------------------------------------------------------
// parseIntField
// ---------------------------------------------------------------------------
describe('BACKUP-032 parseIntField', () => {
it('returns numeric value as-is when finite', () => {
expect(parseIntField(5, 99)).toBe(5);
});
it('floors float numbers', () => {
expect(parseIntField(7.9, 0)).toBe(7);
});
it('parses numeric strings', () => {
expect(parseIntField('12', 0)).toBe(12);
});
it('returns fallback for non-numeric string', () => {
expect(parseIntField('abc', 3)).toBe(3);
});
it('returns fallback for null', () => {
expect(parseIntField(null, 7)).toBe(7);
});
it('returns fallback for undefined', () => {
expect(parseIntField(undefined, 7)).toBe(7);
});
it('returns fallback for Infinity', () => {
expect(parseIntField(Infinity, 5)).toBe(5);
});
it('returns fallback for empty string', () => {
expect(parseIntField('', 4)).toBe(4);
});
});
// ---------------------------------------------------------------------------
// parseAutoBackupBody
// ---------------------------------------------------------------------------
describe('BACKUP-033 parseAutoBackupBody', () => {
it('parses all valid fields', () => {
const result = parseAutoBackupBody({
enabled: true,
interval: 'weekly',
keep_days: 14,
hour: 6,
day_of_week: 5,
day_of_month: 15,
});
expect(result).toEqual({
enabled: true,
interval: 'weekly',
keep_days: 14,
hour: 6,
day_of_week: 5,
day_of_month: 15,
});
});
it('defaults to daily when interval is invalid', () => {
const result = parseAutoBackupBody({ interval: 'not-valid' });
expect(result.interval).toBe('daily');
});
it('clamps hour to 0-23', () => {
expect(parseAutoBackupBody({ hour: 999 }).hour).toBe(23);
expect(parseAutoBackupBody({ hour: -1 }).hour).toBe(0);
});
it('clamps day_of_week to 0-6', () => {
expect(parseAutoBackupBody({ day_of_week: 10 }).day_of_week).toBe(6);
expect(parseAutoBackupBody({ day_of_week: -1 }).day_of_week).toBe(0);
});
it('clamps day_of_month to 1-28', () => {
expect(parseAutoBackupBody({ day_of_month: 99 }).day_of_month).toBe(28);
expect(parseAutoBackupBody({ day_of_month: 0 }).day_of_month).toBe(1);
});
it('treats enabled = "true" string as true', () => {
expect(parseAutoBackupBody({ enabled: 'true' }).enabled).toBe(true);
});
it('treats enabled = 1 as true', () => {
expect(parseAutoBackupBody({ enabled: 1 }).enabled).toBe(true);
});
it('treats enabled = false as false', () => {
expect(parseAutoBackupBody({ enabled: false }).enabled).toBe(false);
});
});
// ---------------------------------------------------------------------------
// isValidBackupFilename
// ---------------------------------------------------------------------------
describe('BACKUP-034 isValidBackupFilename', () => {
it('accepts valid backup filename', () => {
expect(isValidBackupFilename('backup-2026-04-06T12-00-00.zip')).toBe(true);
});
it('rejects path traversal', () => {
expect(isValidBackupFilename('../../etc/passwd')).toBe(false);
});
it('rejects filename without .zip extension', () => {
expect(isValidBackupFilename('backup-2026-04-06T12-00-00.tar.gz')).toBe(false);
});
it('rejects filename with spaces', () => {
expect(isValidBackupFilename('backup 2026.zip')).toBe(false);
});
it('rejects empty string', () => {
expect(isValidBackupFilename('')).toBe(false);
});
it('accepts filename with hyphens and underscores', () => {
expect(isValidBackupFilename('backup-my_trek-2026.zip')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// checkRateLimit
// ---------------------------------------------------------------------------
describe('BACKUP-035 checkRateLimit', () => {
// Each test uses a unique key to avoid state pollution between tests
it('allows first request', () => {
expect(checkRateLimit('test-key-1', 3, BACKUP_RATE_WINDOW)).toBe(true);
});
it('allows requests up to maxAttempts', () => {
const key = 'test-key-2';
expect(checkRateLimit(key, 2, BACKUP_RATE_WINDOW)).toBe(true);
expect(checkRateLimit(key, 2, BACKUP_RATE_WINDOW)).toBe(true);
});
it('blocks request exceeding maxAttempts within window', () => {
const key = 'test-key-3';
checkRateLimit(key, 2, BACKUP_RATE_WINDOW);
checkRateLimit(key, 2, BACKUP_RATE_WINDOW);
expect(checkRateLimit(key, 2, BACKUP_RATE_WINDOW)).toBe(false);
});
it('resets counter after window expires', () => {
vi.useFakeTimers();
const key = 'test-key-4';
const windowMs = 100;
checkRateLimit(key, 1, windowMs);
checkRateLimit(key, 1, windowMs); // this one is blocked
vi.advanceTimersByTime(200);
// After window expires, should be allowed again
expect(checkRateLimit(key, 1, windowMs)).toBe(true);
vi.useRealTimers();
});
});
// ---------------------------------------------------------------------------
// createBackup
// ---------------------------------------------------------------------------
describe('BACKUP-036 createBackup', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('BACKUP-036a — happy path: creates zip and returns BackupInfo', async () => {
// Set up fs mocks
fsMock.existsSync.mockImplementation((p: string) => {
// backupsDir exists, dbPath does not (skip DB file), uploadsDir does not exist
return false;
});
fsMock.mkdirSync.mockReturnValue(undefined);
// Mock WriteStream with event emitter behaviour
const writableEvents: Record<string, Function> = {};
const fakeWriteStream = {
on: vi.fn((event: string, cb: Function) => {
writableEvents[event] = cb;
}),
};
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
// Mock archiver instance
archiverInstanceMock.on.mockImplementation((event: string, cb: Function) => {
// noop — no error
});
archiverInstanceMock.pipe.mockReturnValue(undefined);
archiverInstanceMock.finalize.mockImplementation(() => {
// Trigger 'close' on the output stream to resolve the Promise
if (writableEvents['close']) writableEvents['close']();
});
archiverMock.mockReturnValue(archiverInstanceMock);
fsMock.statSync.mockReturnValue({ size: 2048, birthtime: new Date('2026-04-06T12:00:00Z') });
const result = await createBackup();
expect(result).toHaveProperty('filename');
expect(result.filename).toMatch(/^backup-.*\.zip$/);
expect(result.size).toBe(2048);
expect(result.sizeText).toBe('2.0 KB');
expect(result).toHaveProperty('created_at');
expect(archiverMock).toHaveBeenCalledWith('zip', { zlib: { level: 9 } });
expect(archiverInstanceMock.pipe).toHaveBeenCalled();
expect(archiverInstanceMock.finalize).toHaveBeenCalled();
});
it('BACKUP-036b — WAL checkpoint error is swallowed (non-critical)', async () => {
// db.exec throws on WAL checkpoint
dbMock.db.exec.mockImplementationOnce(() => { throw new Error('WAL checkpoint failed'); });
const writableEvents: Record<string, Function> = {};
const fakeWriteStream = {
on: vi.fn((event: string, cb: Function) => {
writableEvents[event] = cb;
}),
};
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
fsMock.existsSync.mockReturnValue(false);
fsMock.mkdirSync.mockReturnValue(undefined);
archiverInstanceMock.on.mockImplementation((_event: string, _cb: Function) => {});
archiverInstanceMock.pipe.mockReturnValue(undefined);
archiverInstanceMock.finalize.mockImplementation(() => {
if (writableEvents['close']) writableEvents['close']();
});
archiverMock.mockReturnValue(archiverInstanceMock);
fsMock.statSync.mockReturnValue({ size: 512, birthtime: new Date('2026-04-06T12:00:00Z') });
// Should not throw even though WAL checkpoint failed
const result = await createBackup();
expect(result).toHaveProperty('filename');
expect(result.size).toBe(512);
});
it('BACKUP-036c — archiver error cleans up partial file and re-throws', async () => {
fsMock.existsSync.mockReturnValue(false);
fsMock.mkdirSync.mockReturnValue(undefined);
const writableEvents: Record<string, Function> = {};
const archiveEvents: Record<string, Function> = {};
const fakeWriteStream = {
on: vi.fn((event: string, cb: Function) => {
writableEvents[event] = cb;
}),
};
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
archiverInstanceMock.on.mockImplementation((event: string, cb: Function) => {
archiveEvents[event] = cb;
});
archiverInstanceMock.pipe.mockReturnValue(undefined);
archiverInstanceMock.finalize.mockImplementation(() => {
// Simulate archive error instead of success
if (archiveEvents['error']) archiveEvents['error'](new Error('disk full'));
});
archiverMock.mockReturnValue(archiverInstanceMock);
// The output file "exists" after partial write so cleanup runs
fsMock.existsSync.mockImplementation((p: string) => {
// Return true only when checking the output path (ends with .zip)
return String(p).endsWith('.zip');
});
fsMock.unlinkSync.mockReturnValue(undefined);
await expect(createBackup()).rejects.toThrow('disk full');
// Partial file should have been removed
expect(fsMock.unlinkSync).toHaveBeenCalled();
});
it('BACKUP-036d — includes travel.db when it exists', async () => {
fsMock.existsSync.mockImplementation((p: string) => {
// backupsDir does not need to be created (exists), dbPath exists, no uploads
if (String(p).endsWith('travel.db')) return true;
return false;
});
fsMock.mkdirSync.mockReturnValue(undefined);
const writableEvents: Record<string, Function> = {};
const fakeWriteStream = {
on: vi.fn((event: string, cb: Function) => {
writableEvents[event] = cb;
}),
};
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
archiverInstanceMock.on.mockImplementation((_e: string, _cb: Function) => {});
archiverInstanceMock.pipe.mockReturnValue(undefined);
archiverInstanceMock.finalize.mockImplementation(() => {
if (writableEvents['close']) writableEvents['close']();
});
archiverMock.mockReturnValue(archiverInstanceMock);
fsMock.statSync.mockReturnValue({ size: 1024, birthtime: new Date('2026-04-06T12:00:00Z') });
await createBackup();
// archive.file should have been called with the db path
expect(archiverInstanceMock.file).toHaveBeenCalledWith(
expect.stringContaining('travel.db'),
{ name: 'travel.db' }
);
});
it('BACKUP-036e — includes uploads directory when it exists', async () => {
fsMock.existsSync.mockImplementation((p: string) => {
if (String(p).endsWith('uploads')) return true;
return false;
});
fsMock.mkdirSync.mockReturnValue(undefined);
const writableEvents: Record<string, Function> = {};
const fakeWriteStream = {
on: vi.fn((event: string, cb: Function) => {
writableEvents[event] = cb;
}),
};
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
archiverInstanceMock.on.mockImplementation((_e: string, _cb: Function) => {});
archiverInstanceMock.pipe.mockReturnValue(undefined);
archiverInstanceMock.finalize.mockImplementation(() => {
if (writableEvents['close']) writableEvents['close']();
});
archiverMock.mockReturnValue(archiverInstanceMock);
fsMock.statSync.mockReturnValue({ size: 1024, birthtime: new Date('2026-04-06T12:00:00Z') });
await createBackup();
expect(archiverInstanceMock.directory).toHaveBeenCalledWith(
expect.stringContaining('uploads'),
'uploads'
);
});
});
// ---------------------------------------------------------------------------
// deleteBackup
// ---------------------------------------------------------------------------
describe('BACKUP-037 deleteBackup', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('BACKUP-037a — happy path: calls unlinkSync with correct path', () => {
fsMock.unlinkSync.mockReturnValue(undefined);
deleteBackup('backup-2026-04-06T12-00-00.zip');
expect(fsMock.unlinkSync).toHaveBeenCalledOnce();
expect(fsMock.unlinkSync).toHaveBeenCalledWith(
expect.stringContaining('backup-2026-04-06T12-00-00.zip')
);
});
it('BACKUP-037b — throws when unlinkSync throws (file not found)', () => {
fsMock.unlinkSync.mockImplementation(() => {
const err: NodeJS.ErrnoException = new Error('ENOENT: no such file or directory');
err.code = 'ENOENT';
throw err;
});
expect(() => deleteBackup('backup-missing.zip')).toThrow('ENOENT');
});
});
// ---------------------------------------------------------------------------
// restoreFromZip
// ---------------------------------------------------------------------------
describe('BACKUP-038 restoreFromZip', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('BACKUP-038a — returns error when travel.db not found in zip', async () => {
// Simulate successful extraction but missing travel.db
const fakeReadStream = { pipe: vi.fn() };
const fakeExtractStream = { promise: vi.fn().mockResolvedValue(undefined) };
fsMock.createReadStream.mockReturnValue(fakeReadStream);
fakeReadStream.pipe.mockReturnValue(fakeExtractStream);
unzipperMock.Extract.mockReturnValue(fakeExtractStream);
// extractedDb does not exist
fsMock.existsSync.mockImplementation((p: string) => {
if (String(p).endsWith('travel.db')) return false;
return true; // extractDir exists for cleanup
});
fsMock.rmSync.mockReturnValue(undefined);
const result = await restoreFromZip('/data/tmp/upload.zip');
expect(result.success).toBe(false);
expect(result.error).toMatch(/travel\.db not found/i);
expect(result.status).toBe(400);
});
});
// ---------------------------------------------------------------------------
// better-sqlite3 mock — hoisted by Vitest regardless of file position
// ---------------------------------------------------------------------------
const DatabaseMock = vi.hoisted(() => vi.fn());
vi.mock('better-sqlite3', () => ({ default: DatabaseMock }));
// ---------------------------------------------------------------------------
// backupFilePath
// ---------------------------------------------------------------------------
describe('BACKUP-039 backupFilePath', () => {
it('BACKUP-039a — returns a path ending with the given filename', () => {
const result = backupFilePath('backup-test.zip');
expect(result).toMatch(/backup-test\.zip$/);
});
});
// ---------------------------------------------------------------------------
// backupFileExists
// ---------------------------------------------------------------------------
describe('BACKUP-040 backupFileExists', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('BACKUP-040a — returns true when existsSync returns true', () => {
fsMock.existsSync.mockReturnValue(true);
expect(backupFileExists('backup-2026-01-01T00-00-00.zip')).toBe(true);
expect(fsMock.existsSync).toHaveBeenCalledWith(
expect.stringContaining('backup-2026-01-01T00-00-00.zip')
);
});
it('BACKUP-040b — returns false when existsSync returns false', () => {
fsMock.existsSync.mockReturnValue(false);
expect(backupFileExists('backup-missing.zip')).toBe(false);
});
});
// ---------------------------------------------------------------------------
// listBackups
// ---------------------------------------------------------------------------
describe('BACKUP-041 listBackups', () => {
beforeEach(() => {
vi.clearAllMocks();
// ensureBackupsDir: backupsDir already exists so mkdirSync is not called
fsMock.existsSync.mockReturnValue(true);
});
it('BACKUP-041a — returns empty array when no .zip files in directory', () => {
fsMock.readdirSync.mockReturnValue([]);
expect(listBackups()).toEqual([]);
});
it('BACKUP-041b — returns BackupInfo array for each .zip file', () => {
fsMock.readdirSync.mockReturnValue(['backup-2026-01-01T00-00-00.zip']);
fsMock.statSync.mockReturnValue({
size: 1024,
birthtime: new Date('2026-01-01T00:00:00Z'),
});
const result = listBackups();
expect(result).toHaveLength(1);
expect(result[0].filename).toBe('backup-2026-01-01T00-00-00.zip');
expect(result[0].size).toBe(1024);
expect(result[0].sizeText).toBe('1.0 KB');
expect(result[0].created_at).toBe('2026-01-01T00:00:00.000Z');
});
it('BACKUP-041c — sorts results newest-first', () => {
fsMock.readdirSync.mockReturnValue([
'backup-2026-01-01T00-00-00.zip',
'backup-2026-06-01T00-00-00.zip',
]);
fsMock.statSync.mockImplementation((p: string) => {
if (String(p).includes('2026-01-01')) {
return { size: 512, birthtime: new Date('2026-01-01T00:00:00Z') };
}
return { size: 2048, birthtime: new Date('2026-06-01T00:00:00Z') };
});
const result = listBackups();
expect(result).toHaveLength(2);
expect(result[0].filename).toBe('backup-2026-06-01T00-00-00.zip');
expect(result[1].filename).toBe('backup-2026-01-01T00-00-00.zip');
});
it('BACKUP-041d — filters out non-.zip files', () => {
fsMock.readdirSync.mockReturnValue([
'backup-2026-01-01T00-00-00.zip',
'README.txt',
'backup-partial.tar.gz',
]);
fsMock.statSync.mockReturnValue({
size: 1024,
birthtime: new Date('2026-01-01T00:00:00Z'),
});
const result = listBackups();
expect(result).toHaveLength(1);
expect(result[0].filename).toBe('backup-2026-01-01T00-00-00.zip');
});
});
// ---------------------------------------------------------------------------
// restoreFromZip — extended paths (BACKUP-042 through BACKUP-046)
// ---------------------------------------------------------------------------
/** Shared helper: configures the stream mocks so extraction succeeds. */
function setupSuccessfulExtraction() {
const fakeExtractStream = { promise: vi.fn().mockResolvedValue(undefined) };
const fakeReadStream = { pipe: vi.fn().mockReturnValue(fakeExtractStream) };
fsMock.createReadStream.mockReturnValue(fakeReadStream);
unzipperMock.Extract.mockReturnValue(fakeExtractStream);
return { fakeReadStream, fakeExtractStream };
}
describe('BACKUP-042 restoreFromZip — integrity check fails', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('BACKUP-042a — returns status 400 with integrity check error message', async () => {
setupSuccessfulExtraction();
fsMock.existsSync.mockImplementation((p: string) =>
String(p).endsWith('travel.db')
);
fsMock.rmSync.mockReturnValue(undefined);
const fakeDbInstance = {
prepare: vi.fn().mockReturnValue({
get: vi.fn().mockReturnValue({ integrity_check: 'corruption' }),
all: vi.fn(),
}),
close: vi.fn(),
};
DatabaseMock.mockReturnValue(fakeDbInstance);
const result = await restoreFromZip('/data/tmp/upload.zip');
expect(result.success).toBe(false);
expect(result.status).toBe(400);
expect(result.error).toMatch(/integrity check/i);
expect(fsMock.rmSync).toHaveBeenCalled();
});
});
describe('BACKUP-043 restoreFromZip — missing required table', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('BACKUP-043a — returns status 400 with missing required table error', async () => {
setupSuccessfulExtraction();
fsMock.existsSync.mockImplementation((p: string) =>
String(p).endsWith('travel.db')
);
fsMock.rmSync.mockReturnValue(undefined);
const fakeDbInstance = {
prepare: vi.fn()
.mockReturnValueOnce({
get: vi.fn().mockReturnValue({ integrity_check: 'ok' }),
})
.mockReturnValueOnce({
all: vi.fn().mockReturnValue([{ name: 'users' }, { name: 'trips' }]),
}),
close: vi.fn(),
};
DatabaseMock.mockReturnValue(fakeDbInstance);
const result = await restoreFromZip('/data/tmp/upload.zip');
expect(result.success).toBe(false);
expect(result.status).toBe(400);
expect(result.error).toMatch(/missing required table/i);
expect(fsMock.rmSync).toHaveBeenCalled();
});
});
describe('BACKUP-044 restoreFromZip — Database constructor throws (invalid SQLite)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('BACKUP-044a — returns status 400 with "not a valid SQLite database" error', async () => {
setupSuccessfulExtraction();
fsMock.existsSync.mockImplementation((p: string) =>
String(p).endsWith('travel.db')
);
fsMock.rmSync.mockReturnValue(undefined);
DatabaseMock.mockImplementation(() => {
throw new Error('file is not a database');
});
const result = await restoreFromZip('/data/tmp/upload.zip');
expect(result.success).toBe(false);
expect(result.status).toBe(400);
expect(result.error).toMatch(/not a valid SQLite database/i);
expect(fsMock.rmSync).toHaveBeenCalled();
});
});
describe('BACKUP-045 restoreFromZip — full success path (no uploads)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
function setupAllTablesPresent() {
const fakeDbInstance = {
prepare: vi.fn()
.mockReturnValueOnce({
get: vi.fn().mockReturnValue({ integrity_check: 'ok' }),
})
.mockReturnValueOnce({
all: vi.fn().mockReturnValue([
{ name: 'users' },
{ name: 'trips' },
{ name: 'trip_members' },
{ name: 'places' },
{ name: 'days' },
]),
}),
close: vi.fn(),
};
DatabaseMock.mockReturnValue(fakeDbInstance);
return fakeDbInstance;
}
it('BACKUP-045a — returns { success: true } on full success', async () => {
setupSuccessfulExtraction();
setupAllTablesPresent();
fsMock.existsSync.mockImplementation((p: string) => {
if (String(p).endsWith('travel.db')) return true;
if (String(p).includes('uploads')) return false;
return true;
});
fsMock.unlinkSync.mockReturnValue(undefined);
fsMock.copyFileSync.mockReturnValue(undefined);
fsMock.rmSync.mockReturnValue(undefined);
const result = await restoreFromZip('/data/tmp/upload.zip');
expect(result).toEqual({ success: true });
});
it('BACKUP-045b — closeDb is called before file copy operations', async () => {
setupSuccessfulExtraction();
setupAllTablesPresent();
const callOrder: string[] = [];
dbMock.closeDb.mockImplementation(() => { callOrder.push('closeDb'); });
fsMock.copyFileSync.mockImplementation(() => { callOrder.push('copyFileSync'); });
fsMock.unlinkSync.mockReturnValue(undefined);
fsMock.rmSync.mockReturnValue(undefined);
fsMock.existsSync.mockImplementation((p: string) => {
if (String(p).endsWith('travel.db')) return true;
if (String(p).includes('uploads')) return false;
return true;
});
await restoreFromZip('/data/tmp/upload.zip');
expect(callOrder.indexOf('closeDb')).toBeLessThan(callOrder.indexOf('copyFileSync'));
});
it('BACKUP-045c — reinitialize is called even when copyFileSync throws', async () => {
setupSuccessfulExtraction();
setupAllTablesPresent();
fsMock.existsSync.mockImplementation((p: string) => {
if (String(p).endsWith('travel.db')) return true;
if (String(p).includes('uploads')) return false;
return true;
});
fsMock.unlinkSync.mockReturnValue(undefined);
fsMock.copyFileSync.mockImplementation(() => {
throw new Error('disk full');
});
fsMock.rmSync.mockReturnValue(undefined);
await expect(restoreFromZip('/data/tmp/upload.zip')).rejects.toThrow('disk full');
expect(dbMock.reinitialize).toHaveBeenCalled();
});
});
describe('BACKUP-046 restoreFromZip — with uploads directory', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('BACKUP-046a — cpSync is called to copy uploads when they exist in the archive', async () => {
setupSuccessfulExtraction();
const fakeDbInstance = {
prepare: vi.fn()
.mockReturnValueOnce({
get: vi.fn().mockReturnValue({ integrity_check: 'ok' }),
})
.mockReturnValueOnce({
all: vi.fn().mockReturnValue([
{ name: 'users' },
{ name: 'trips' },
{ name: 'trip_members' },
{ name: 'places' },
{ name: 'days' },
]),
}),
close: vi.fn(),
};
DatabaseMock.mockReturnValue(fakeDbInstance);
fsMock.existsSync.mockImplementation((p: string) => {
// travel.db present, extractedUploads present
if (String(p).endsWith('travel.db')) return true;
if (String(p).includes('uploads')) return true;
return true;
});
fsMock.readdirSync.mockImplementation((p: string) => {
// uploadsDir has one subdirectory 'photos'; 'photos' has one file
if (String(p).includes('uploads') && !String(p).includes('restore-')) {
return ['photos'] as any;
}
if (String(p).includes('photos')) return ['img1.jpg'] as any;
return [] as any;
});
fsMock.statSync.mockReturnValue({ isDirectory: () => true } as any);
fsMock.unlinkSync.mockReturnValue(undefined);
fsMock.copyFileSync.mockReturnValue(undefined);
fsMock.cpSync.mockReturnValue(undefined);
fsMock.rmSync.mockReturnValue(undefined);
await restoreFromZip('/data/tmp/upload.zip');
expect(fsMock.cpSync).toHaveBeenCalledWith(
expect.stringContaining('uploads'),
expect.stringContaining('uploads'),
{ recursive: true, force: true }
);
});
});
// ---------------------------------------------------------------------------
// updateAutoSettings
// ---------------------------------------------------------------------------
describe('BACKUP-047 updateAutoSettings', () => {
let schedulerMock: typeof import('../../../src/scheduler');
beforeEach(async () => {
vi.clearAllMocks();
schedulerMock = await import('../../../src/scheduler');
});
it('BACKUP-047a — calls scheduler.saveSettings with the parsed settings', () => {
updateAutoSettings({ enabled: true, interval: 'weekly', hour: 6 });
expect(schedulerMock.saveSettings).toHaveBeenCalledOnce();
expect(schedulerMock.saveSettings).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true, interval: 'weekly', hour: 6 })
);
});
it('BACKUP-047b — calls scheduler.start() after saving', () => {
const saveOrder: string[] = [];
(schedulerMock.saveSettings as ReturnType<typeof vi.fn>).mockImplementation(() => {
saveOrder.push('saveSettings');
});
(schedulerMock.start as ReturnType<typeof vi.fn>).mockImplementation(() => {
saveOrder.push('start');
});
updateAutoSettings({ enabled: false });
expect(saveOrder).toEqual(['saveSettings', 'start']);
});
it('BACKUP-047c — returns the parsed settings object', () => {
const result = updateAutoSettings({
enabled: true,
interval: 'monthly',
keep_days: 30,
hour: 3,
day_of_week: 2,
day_of_month: 15,
});
expect(result).toEqual({
enabled: true,
interval: 'monthly',
keep_days: 30,
hour: 3,
day_of_week: 2,
day_of_month: 15,
});
});
});
@@ -29,7 +29,7 @@ const mockDb = vi.hoisted(() => {
vi.mock('../../../src/db/database', () => mockDb);
import { calculateSettlement, avatarUrl } from '../../../src/services/budgetService';
import { calculateSettlement } from '../../../src/services/budgetService';
import type { BudgetItem, BudgetItemMember } from '../../../src/types';
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -65,22 +65,6 @@ beforeEach(() => {
setupDb([], []);
});
// ── avatarUrl ────────────────────────────────────────────────────────────────
describe('avatarUrl', () => {
it('returns /uploads/avatars/<filename> when avatar is set', () => {
expect(avatarUrl({ avatar: 'photo.jpg' })).toBe('/uploads/avatars/photo.jpg');
});
it('returns null when avatar is null', () => {
expect(avatarUrl({ avatar: null })).toBeNull();
});
it('returns null when avatar is undefined', () => {
expect(avatarUrl({})).toBeNull();
});
});
// ── calculateSettlement ──────────────────────────────────────────────────────
describe('calculateSettlement', () => {
@@ -0,0 +1,189 @@
/**
* Unit tests for categoryService CAT-SVC-001 through CAT-SVC-015.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import {
listCategories,
createCategory,
getCategoryById,
updateCategory,
deleteCategory,
} from '../../../src/services/categoryService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── listCategories ────────────────────────────────────────────────────────────
describe('listCategories', () => {
it('CAT-SVC-001 — returns an array (seeded defaults are present after migrations)', () => {
// Migrations seed default categories, so the list is never empty in a fully initialized DB
const cats = listCategories() as any[];
expect(Array.isArray(cats)).toBe(true);
expect(cats.length).toBeGreaterThan(0);
});
it('CAT-SVC-002 — results are ordered by name ascending (custom categories sort correctly)', () => {
const { user } = createUser(testDb);
createCategory(user.id, 'Zoo');
createCategory(user.id, 'Aquarium');
// Migrations seed default categories; verify ordering by checking our custom ones appear in sorted order
const names = (listCategories() as any[]).map((c: any) => c.name);
const aquariumIdx = names.indexOf('Aquarium');
const zooIdx = names.indexOf('Zoo');
expect(aquariumIdx).toBeGreaterThanOrEqual(0);
expect(zooIdx).toBeGreaterThanOrEqual(0);
expect(aquariumIdx).toBeLessThan(zooIdx);
});
it('CAT-SVC-003 — returns categories from all users (including seeded defaults)', () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
const before = (listCategories() as any[]).length;
createCategory(a.id, 'Cat-A');
createCategory(b.id, 'Cat-B');
expect(listCategories()).toHaveLength(before + 2);
});
});
// ── createCategory ────────────────────────────────────────────────────────────
describe('createCategory', () => {
it('CAT-SVC-004 — creates a category with name, color, and icon', () => {
const { user } = createUser(testDb);
const cat = createCategory(user.id, 'Restaurant', '#ff5500', '🍽️') as any;
expect(cat.name).toBe('Restaurant');
expect(cat.color).toBe('#ff5500');
expect(cat.icon).toBe('🍽️');
expect(cat.user_id).toBe(user.id);
});
it('CAT-SVC-005 — defaults color to #6366f1 when not provided', () => {
const { user } = createUser(testDb);
const cat = createCategory(user.id, 'Default Color') as any;
expect(cat.color).toBe('#6366f1');
});
it('CAT-SVC-006 — defaults icon to 📍 when not provided', () => {
const { user } = createUser(testDb);
const cat = createCategory(user.id, 'Default Icon') as any;
expect(cat.icon).toBe('📍');
});
it('CAT-SVC-007 — returns the inserted row with an id', () => {
const { user } = createUser(testDb);
const cat = createCategory(user.id, 'WithId') as any;
expect(typeof cat.id).toBe('number');
expect(cat.id).toBeGreaterThan(0);
});
});
// ── getCategoryById ───────────────────────────────────────────────────────────
describe('getCategoryById', () => {
it('CAT-SVC-008 — returns category for a valid id', () => {
const { user } = createUser(testDb);
const created = createCategory(user.id, 'Find Me') as any;
const found = getCategoryById(created.id) as any;
expect(found).toBeDefined();
expect(found.name).toBe('Find Me');
});
it('CAT-SVC-009 — returns undefined for non-existent id', () => {
expect(getCategoryById(99999)).toBeUndefined();
});
it('CAT-SVC-010 — accepts string id (coerced by SQLite)', () => {
const { user } = createUser(testDb);
const created = createCategory(user.id, 'StringId') as any;
const found = getCategoryById(String(created.id)) as any;
expect(found).toBeDefined();
expect(found.id).toBe(created.id);
});
});
// ── updateCategory ────────────────────────────────────────────────────────────
describe('updateCategory', () => {
it('CAT-SVC-011 — updates name, color, and icon', () => {
const { user } = createUser(testDb);
const cat = createCategory(user.id, 'Old', '#aaaaaa', '❓') as any;
const updated = updateCategory(cat.id, 'New', '#bbbbbb', '✅') as any;
expect(updated.name).toBe('New');
expect(updated.color).toBe('#bbbbbb');
expect(updated.icon).toBe('✅');
});
it('CAT-SVC-012 — COALESCE: omitting name preserves existing name', () => {
const { user } = createUser(testDb);
const cat = createCategory(user.id, 'KeepName', '#aaaaaa', '⭐') as any;
const updated = updateCategory(cat.id, undefined, '#cccccc', '🔥') as any;
expect(updated.name).toBe('KeepName');
expect(updated.color).toBe('#cccccc');
});
it('CAT-SVC-013 — COALESCE: omitting color preserves existing color', () => {
const { user } = createUser(testDb);
const cat = createCategory(user.id, 'KeepColor', '#dddddd', '⭐') as any;
const updated = updateCategory(cat.id, 'NewName', undefined, '🌟') as any;
expect(updated.name).toBe('NewName');
expect(updated.color).toBe('#dddddd');
});
});
// ── deleteCategory ────────────────────────────────────────────────────────────
describe('deleteCategory', () => {
it('CAT-SVC-014 — deletes the category from the database', () => {
const { user } = createUser(testDb);
const cat = createCategory(user.id, 'ToDelete') as any;
deleteCategory(cat.id);
expect(getCategoryById(cat.id)).toBeUndefined();
});
it('CAT-SVC-015 — deleting a non-existent category does not throw', () => {
expect(() => deleteCategory(99999)).not.toThrow();
});
});
@@ -0,0 +1,403 @@
/**
* Unit tests for dayService DAY-SVC-001 through DAY-SVC-030.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: any) => {
const place: any = db.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);
if (!place) return null;
const tags = db.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: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAssignment, createDayAccommodation } from '../../helpers/factories';
import {
verifyTripAccess,
getAssignmentsForDay,
listDays,
createDay as svcCreateDay,
getDay,
updateDay,
deleteDay,
listAccommodations,
validateAccommodationRefs,
createAccommodation,
getAccommodation,
updateAccommodation,
deleteAccommodation,
} from '../../../src/services/dayService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── verifyTripAccess ──────────────────────────────────────────────────────────
describe('verifyTripAccess', () => {
it('DAY-SVC-001 — returns trip row for owner', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = verifyTripAccess(trip.id, user.id) as any;
expect(result).toBeDefined();
expect(result.id).toBe(trip.id);
});
it('DAY-SVC-002 — returns falsy for non-member', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
expect(verifyTripAccess(trip.id, stranger.id)).toBeFalsy();
});
});
// ── getAssignmentsForDay ──────────────────────────────────────────────────────
describe('getAssignmentsForDay', () => {
it('DAY-SVC-003 — returns empty array when day has no assignments', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
expect(getAssignmentsForDay(day.id)).toEqual([]);
});
it('DAY-SVC-004 — returns assignments with nested place object', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower', lat: 48.8, lng: 2.3 }) as any;
createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
const assignments = getAssignmentsForDay(day.id) as any[];
expect(assignments).toHaveLength(1);
expect(assignments[0].place).toBeDefined();
expect(assignments[0].place.name).toBe('Eiffel Tower');
expect(assignments[0].place.lat).toBe(48.8);
});
it('DAY-SVC-005 — assignment includes tags array (empty when place has none)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'No Tags' }) as any;
createDayAssignment(testDb, day.id, place.id);
const assignments = getAssignmentsForDay(day.id) as any[];
expect(Array.isArray(assignments[0].place.tags)).toBe(true);
});
it('DAY-SVC-006 — assignments are ordered by order_index ASC', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const p1 = createPlace(testDb, trip.id, { name: 'Second' }) as any;
const p2 = createPlace(testDb, trip.id, { name: 'First' }) as any;
createDayAssignment(testDb, day.id, p1.id, { order_index: 2 });
createDayAssignment(testDb, day.id, p2.id, { order_index: 1 });
const assignments = getAssignmentsForDay(day.id) as any[];
expect(assignments[0].place.name).toBe('First');
expect(assignments[1].place.name).toBe('Second');
});
});
// ── listDays ──────────────────────────────────────────────────────────────────
describe('listDays', () => {
it('DAY-SVC-007 — returns { days: [] } for trip with no days', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = listDays(trip.id) as any;
expect(result.days).toEqual([]);
});
it('DAY-SVC-008 — returns days with assignments nested', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createDay(testDb, trip.id);
const result = listDays(trip.id) as any;
expect(result.days).toHaveLength(1);
expect(Array.isArray(result.days[0].assignments)).toBe(true);
});
});
// ── createDay ─────────────────────────────────────────────────────────────────
describe('createDay (service)', () => {
it('DAY-SVC-009 — creates a day with auto-incremented day_number', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const d1 = svcCreateDay(trip.id) as any;
const d2 = svcCreateDay(trip.id) as any;
expect(d1.day_number).toBe(1);
expect(d2.day_number).toBe(2);
});
it('DAY-SVC-010 — returns day with empty assignments array', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = svcCreateDay(trip.id) as any;
expect(Array.isArray(day.assignments)).toBe(true);
expect(day.assignments).toHaveLength(0);
});
});
// ── getDay / updateDay / deleteDay ────────────────────────────────────────────
describe('getDay', () => {
it('DAY-SVC-011 — returns day when id and tripId match', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const found = getDay(day.id, trip.id) as any;
expect(found).toBeDefined();
expect(found.id).toBe(day.id);
});
it('DAY-SVC-012 — returns undefined for non-existent day', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(getDay(99999, trip.id)).toBeUndefined();
});
});
describe('updateDay', () => {
it('DAY-SVC-013 — updates notes and returns updated day with assignments', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const updated = updateDay(day.id, day, { notes: 'Updated notes' }) as any;
expect(updated.notes).toBe('Updated notes');
expect(Array.isArray(updated.assignments)).toBe(true);
});
it('DAY-SVC-014 — updates title', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const updated = updateDay(day.id, day, { title: 'Day 1 - City Tour' }) as any;
expect(updated.title).toBe('Day 1 - City Tour');
});
});
describe('deleteDay', () => {
it('DAY-SVC-015 — deletes the day', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
deleteDay(day.id);
expect(getDay(day.id, trip.id)).toBeUndefined();
});
});
// ── validateAccommodationRefs ─────────────────────────────────────────────────
describe('validateAccommodationRefs', () => {
it('DAY-SVC-016 — returns no errors when all refs are valid', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const errors = validateAccommodationRefs(trip.id, place.id, day.id, day.id);
expect(errors).toHaveLength(0);
});
it('DAY-SVC-017 — returns error when place does not exist in trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const errors = validateAccommodationRefs(trip.id, 99999, day.id, day.id);
expect(errors.some((e: any) => e.field === 'place_id')).toBe(true);
});
it('DAY-SVC-018 — returns error when start_day_id is invalid', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const errors = validateAccommodationRefs(trip.id, place.id, 99999, day.id);
expect(errors.some((e: any) => e.field === 'start_day_id')).toBe(true);
});
});
// ── createAccommodation ───────────────────────────────────────────────────────
describe('createAccommodation', () => {
it('DAY-SVC-019 — creates accommodation and returns it with place info', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' }) as any;
const accom = createAccommodation(trip.id, {
place_id: place.id,
start_day_id: day.id,
end_day_id: day.id,
check_in: '15:00',
check_out: '11:00',
}) as any;
expect(accom).toBeDefined();
expect(accom.place_name).toBe('Grand Hotel');
});
it('DAY-SVC-020 — auto-creates a linked reservation', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'City Hotel' }) as any;
const accom = createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
}) as any;
const reservation = testDb.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
expect(reservation).toBeDefined();
expect(reservation.type).toBe('hotel');
expect(reservation.status).toBe('confirmed');
});
});
// ── getAccommodation ──────────────────────────────────────────────────────────
describe('getAccommodation', () => {
it('DAY-SVC-021 — returns accommodation for valid id and trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any;
const found = getAccommodation(accom.id, trip.id) as any;
expect(found).toBeDefined();
expect(found.id).toBe(accom.id);
});
it('DAY-SVC-022 — returns undefined for non-existent accommodation', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(getAccommodation(99999, trip.id)).toBeUndefined();
});
});
// ── updateAccommodation ───────────────────────────────────────────────────────
describe('updateAccommodation', () => {
it('DAY-SVC-023 — updates check-in and check-out times', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
}) as any;
const existing = getAccommodation(accom.id, trip.id)!;
const updated = updateAccommodation(accom.id, existing as any, { check_in: '16:00', check_out: '12:00' }) as any;
expect(updated).toBeDefined();
// Verify linked reservation metadata was synced
const reservation = testDb.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
expect(reservation).toBeDefined();
const meta = JSON.parse(reservation.metadata || '{}');
expect(meta.check_in_time).toBe('16:00');
expect(meta.check_out_time).toBe('12:00');
});
it('DAY-SVC-024 — preserves existing fields when not updated', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
confirmation: 'ABC123',
}) as any;
const existing = getAccommodation(accom.id, trip.id)!;
updateAccommodation(accom.id, existing as any, { check_in: '14:00' });
const row = getAccommodation(accom.id, trip.id) as any;
expect(row.confirmation).toBe('ABC123');
});
});
// ── deleteAccommodation ───────────────────────────────────────────────────────
describe('deleteAccommodation', () => {
it('DAY-SVC-025 — deletes accommodation and its linked reservation', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
}) as any;
const reservation = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
const result = deleteAccommodation(accom.id);
expect(result.linkedReservationId).toBe(reservation.id);
// Accommodation is gone
expect(getAccommodation(accom.id, trip.id)).toBeUndefined();
// Reservation is gone
const deletedRes = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id);
expect(deletedRes).toBeUndefined();
});
it('DAY-SVC-026 — returns null linkedReservationId when no reservation was linked', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any;
// Remove the auto-created reservation so there's no linked one
testDb.prepare('DELETE FROM reservations WHERE accommodation_id = ?').run(accom.id);
const result = deleteAccommodation(accom.id);
expect(result.linkedReservationId).toBeNull();
});
});
@@ -68,4 +68,49 @@ describe('ephemeralTokens', () => {
expect(result).toBeNull();
});
});
describe('startTokenCleanup / stopTokenCleanup', () => {
it('startTokenCleanup starts the interval (second call is no-op)', async () => {
vi.useFakeTimers();
const { createEphemeralToken, consumeEphemeralToken, startTokenCleanup, stopTokenCleanup } = await getModule();
startTokenCleanup();
startTokenCleanup(); // should be no-op, not throw
// Token created while cleanup is running should still be consumable (interval hasn't fired)
const token = createEphemeralToken(1, 'ws')!;
expect(consumeEphemeralToken(token, 'ws')).toBe(1);
stopTokenCleanup();
vi.useRealTimers();
});
it('stopTokenCleanup clears the interval and allows restart', async () => {
vi.useFakeTimers();
const { createEphemeralToken, consumeEphemeralToken, startTokenCleanup, stopTokenCleanup } = await getModule();
startTokenCleanup();
stopTokenCleanup();
stopTokenCleanup(); // calling stop twice should not throw
startTokenCleanup(); // should be able to start again after stop
stopTokenCleanup();
// After stop, tokens should still be consumable (cleanup didn't run)
const token = createEphemeralToken(2, 'download')!;
expect(consumeEphemeralToken(token, 'download')).toBe(2);
vi.useRealTimers();
});
it('cleanup interval removes expired tokens', async () => {
vi.useFakeTimers();
const { createEphemeralToken, consumeEphemeralToken, startTokenCleanup, stopTokenCleanup } = await getModule();
startTokenCleanup();
const token = createEphemeralToken(1, 'ws')!; // 30s TTL
// Advance past TTL AND past cleanup interval (60s)
vi.advanceTimersByTime(65_000);
// Token should have been cleaned up by the interval
const result = consumeEphemeralToken(token, 'ws');
expect(result).toBeNull();
stopTokenCleanup();
vi.useRealTimers();
});
});
});
@@ -0,0 +1,21 @@
/**
* Unit tests for inAppNotificationActions NOTIF-ACT-001 through NOTIF-ACT-008.
* Pure Map registry no DB or external dependencies.
*/
import { describe, it, expect } from 'vitest';
import { getAction } from '../../../src/services/inAppNotificationActions';
describe('getAction — built-in registrations', () => {
it('NOTIF-ACT-001 — test_approve is pre-registered', () => {
const handler = getAction('test_approve');
expect(handler).toBeDefined();
expect(typeof handler).toBe('function');
});
it('NOTIF-ACT-002 — test_deny is pre-registered', () => {
const handler = getAction('test_deny');
expect(handler).toBeDefined();
expect(typeof handler).toBe('function');
});
});
File diff suppressed because it is too large Load Diff
@@ -44,6 +44,7 @@ import {
getAdminGlobalPref,
getActiveChannels,
getAvailableChannels,
isWebhookConfigured,
} from '../../../src/services/notificationPreferencesService';
beforeAll(() => {
@@ -316,3 +317,19 @@ describe('setAdminPreferences', () => {
expect(row?.value).toBe('1');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// isWebhookConfigured
// ─────────────────────────────────────────────────────────────────────────────
describe('isWebhookConfigured', () => {
it('NPREF-026 — returns false when webhook is not in active channels', () => {
// No notification_channels configured → defaults don't include webhook
expect(isWebhookConfigured()).toBe(false);
});
it('NPREF-027 — returns true when webhook is in active channels', () => {
setNotificationChannels(testDb, 'webhook');
expect(isWebhookConfigured()).toBe(true);
});
});
@@ -0,0 +1,391 @@
/**
* Unit tests for oidcService OIDC-SVC-001 through OIDC-SVC-025.
* Covers state management, auth codes, role resolution, findOrCreateUser,
* discover caching, and the ReDoS-sensitive issuer trailing-slash regex.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import {
createState,
consumeState,
createAuthCode,
consumeAuthCode,
resolveOidcRole,
frontendUrl,
findOrCreateUser,
discover,
} from '../../../src/services/oidcService';
const MOCK_CONFIG = {
issuer: 'https://oidc.example.com',
clientId: 'client-id',
clientSecret: 'client-secret',
displayName: 'SSO',
discoveryUrl: null,
};
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
delete process.env.OIDC_ADMIN_VALUE;
delete process.env.OIDC_ADMIN_CLAIM;
delete process.env.NODE_ENV;
});
afterAll(() => {
vi.unstubAllGlobals();
testDb.close();
});
// ── createState / consumeState ────────────────────────────────────────────────
describe('createState / consumeState', () => {
it('OIDC-SVC-001: createState returns a hex token', () => {
const state = createState('https://example.com/callback');
expect(state).toMatch(/^[0-9a-f]{64}$/);
});
it('OIDC-SVC-002: consumeState returns stored data and deletes state', () => {
const state = createState('https://example.com/callback', 'invite-abc');
const data = consumeState(state);
expect(data).not.toBeNull();
expect(data!.redirectUri).toBe('https://example.com/callback');
expect(data!.inviteToken).toBe('invite-abc');
// State is consumed — second call returns null
expect(consumeState(state)).toBeNull();
});
it('OIDC-SVC-003: consumeState returns null for unknown state', () => {
expect(consumeState('not-a-real-state')).toBeNull();
});
it('OIDC-SVC-004: two different states do not conflict', () => {
const s1 = createState('http://a.example.com');
const s2 = createState('http://b.example.com');
expect(s1).not.toBe(s2);
expect(consumeState(s1)!.redirectUri).toBe('http://a.example.com');
expect(consumeState(s2)!.redirectUri).toBe('http://b.example.com');
});
});
// ── createAuthCode / consumeAuthCode ─────────────────────────────────────────
describe('createAuthCode / consumeAuthCode', () => {
it('OIDC-SVC-005: createAuthCode returns a UUID-like string', () => {
const code = createAuthCode('my.jwt.token');
expect(typeof code).toBe('string');
expect(code.length).toBeGreaterThan(0);
});
it('OIDC-SVC-006: consumeAuthCode returns the stored token', () => {
const code = createAuthCode('real.jwt.here');
const result = consumeAuthCode(code);
expect('token' in result).toBe(true);
expect((result as { token: string }).token).toBe('real.jwt.here');
});
it('OIDC-SVC-007: auth code is single-use (second consume returns error)', () => {
const code = createAuthCode('single.use.token');
consumeAuthCode(code); // first use
const second = consumeAuthCode(code);
expect('error' in second).toBe(true);
});
it('OIDC-SVC-008: consumeAuthCode returns error for unknown code', () => {
const result = consumeAuthCode('not-a-real-code');
expect('error' in result).toBe(true);
});
});
// ── resolveOidcRole ───────────────────────────────────────────────────────────
describe('resolveOidcRole', () => {
it('OIDC-SVC-009: returns admin when isFirstUser is true', () => {
expect(resolveOidcRole({ sub: 'x' }, true)).toBe('admin');
});
it('OIDC-SVC-010: returns user when no OIDC_ADMIN_VALUE is set', () => {
delete process.env.OIDC_ADMIN_VALUE;
expect(resolveOidcRole({ sub: 'x', groups: ['admins'] }, false)).toBe('user');
});
it('OIDC-SVC-011: returns admin when groups array contains OIDC_ADMIN_VALUE', () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
expect(resolveOidcRole({ sub: 'x', groups: ['trek-users', 'trek-admins'] }, false)).toBe('admin');
});
it('OIDC-SVC-012: returns user when groups array does not contain OIDC_ADMIN_VALUE', () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
expect(resolveOidcRole({ sub: 'x', groups: ['trek-users'] }, false)).toBe('user');
});
it('OIDC-SVC-013: uses custom OIDC_ADMIN_CLAIM when set', () => {
process.env.OIDC_ADMIN_VALUE = 'superadmin';
process.env.OIDC_ADMIN_CLAIM = 'roles';
expect(resolveOidcRole({ sub: 'x', roles: ['superadmin', 'editor'] }, false)).toBe('admin');
});
it('OIDC-SVC-014: handles string claim (exact match)', () => {
process.env.OIDC_ADMIN_VALUE = 'admin';
process.env.OIDC_ADMIN_CLAIM = 'role';
expect(resolveOidcRole({ sub: 'x', role: 'admin' }, false)).toBe('admin');
expect(resolveOidcRole({ sub: 'x', role: 'editor' }, false)).toBe('user');
});
});
// ── frontendUrl ───────────────────────────────────────────────────────────────
describe('frontendUrl', () => {
it('OIDC-SVC-015: prepends localhost:5173 in non-production', () => {
delete process.env.NODE_ENV;
expect(frontendUrl('/login?oidc_code=abc')).toBe('http://localhost:5173/login?oidc_code=abc');
});
it('OIDC-SVC-016: returns bare path in production', () => {
process.env.NODE_ENV = 'production';
expect(frontendUrl('/login?oidc_code=abc')).toBe('/login?oidc_code=abc');
delete process.env.NODE_ENV;
});
});
// ── discover ──────────────────────────────────────────────────────────────────
describe('discover', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('OIDC-SVC-017: fetches and returns discovery document', async () => {
const doc = {
authorization_endpoint: 'https://oidc.example.com/auth',
token_endpoint: 'https://oidc.example.com/token',
userinfo_endpoint: 'https://oidc.example.com/userinfo',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => doc,
}));
// Use unique issuer to bypass module-level cache from other tests
const result = await discover('https://unique-1.example.com');
expect(result.authorization_endpoint).toBe(doc.authorization_endpoint);
expect(result.token_endpoint).toBe(doc.token_endpoint);
});
it('OIDC-SVC-018: throws when provider returns non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
await expect(discover('https://bad-issuer.example.com')).rejects.toThrow();
});
});
// ── issuer trailing-slash regex (ReDoS guard) ─────────────────────────────────
describe('getOidcConfig issuer trailing-slash regex', () => {
it('OIDC-SVC-019: /\\/+$/ strips trailing slashes in < 5ms', () => {
// The regex /\/+$/ in getOidcConfig: issuer.replace(/\/+$/, '')
// Adversarial input: many trailing slashes — should not backtrack catastrophically
const adversarial = 'https://oidc.example.com' + '/'.repeat(10000);
const start = Date.now();
const result = adversarial.replace(/\/+$/, '');
const elapsed = Date.now() - start;
expect(result).toBe('https://oidc.example.com');
expect(elapsed).toBeLessThan(100);
});
});
// ── findOrCreateUser ──────────────────────────────────────────────────────────
describe('findOrCreateUser', () => {
it('OIDC-SVC-020: finds existing user by oidc_sub', () => {
const { user } = createUser(testDb, { email: 'alice@example.com' });
// Link the sub manually
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-alice-123', MOCK_CONFIG.issuer, user.id);
const result = findOrCreateUser(
{ sub: 'sub-alice-123', email: 'alice@example.com', name: 'Alice' },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.id).toBe(user.id);
});
it('OIDC-SVC-021: finds existing user by email when no sub match', () => {
const { user } = createUser(testDb, { email: 'bob@example.com' });
const result = findOrCreateUser(
{ sub: 'sub-bob-new', email: 'bob@example.com', name: 'Bob' },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.id).toBe(user.id);
});
it('OIDC-SVC-022: creates new user when registration is open', () => {
const result = findOrCreateUser(
{ sub: 'sub-new-1', email: 'newuser@example.com', name: 'New User' },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get();
expect(newUser).toBeDefined();
});
it('OIDC-SVC-023: first user gets admin role', () => {
// DB is empty after resetTestDb
const result = findOrCreateUser(
{ sub: 'sub-first', email: 'first@example.com', name: 'First' },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.role).toBe('admin');
});
it('OIDC-SVC-024: returns registration_disabled error when registration is off', () => {
createUser(testDb, { email: 'existing@example.com' });
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
const result = findOrCreateUser(
{ sub: 'sub-blocked', email: 'blocked@example.com', name: 'Blocked' },
MOCK_CONFIG
);
expect('error' in result).toBe(true);
expect((result as { error: string }).error).toBe('registration_disabled');
});
it('OIDC-SVC-025: links oidc_sub when existing user has none', () => {
const { user } = createUser(testDb, { email: 'charlie@example.com' });
// Ensure no oidc_sub set
testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id);
findOrCreateUser(
{ sub: 'sub-charlie-linked', email: 'charlie@example.com', name: 'Charlie' },
MOCK_CONFIG
);
const updated = testDb.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(user.id) as any;
expect(updated.oidc_sub).toBe('sub-charlie-linked');
});
it('OIDC-SVC-026: existing user role is updated when OIDC claim mapping changes it', () => {
const { user } = createUser(testDb, { email: 'diana@example.com', role: 'user' });
// Link oidc_sub manually so the user is found by sub lookup
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-diana-role', MOCK_CONFIG.issuer, user.id);
process.env.OIDC_ADMIN_VALUE = 'admins';
const result = findOrCreateUser(
{ sub: 'sub-diana-role', email: 'diana@example.com', name: 'Diana', groups: ['admins'] },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.role).toBe('admin');
const dbUser = testDb.prepare('SELECT role FROM users WHERE id = ?').get(user.id) as any;
expect(dbUser.role).toBe('admin');
});
it('OIDC-SVC-027: new user with valid invite token increments used_count', () => {
const { user: creator } = createUser(testDb, { email: 'creator@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-valid', 5, 0, ?)"
).run(creator.id);
const result = findOrCreateUser(
{ sub: 'sub-invite-user', email: 'invitee@example.com', name: 'Invitee' },
MOCK_CONFIG,
'tok-valid'
);
expect('user' in result).toBe(true);
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-valid'").get() as any;
expect(token.used_count).toBe(1);
});
it('OIDC-SVC-028: new user with expired invite token is created but invite is ignored', () => {
const { user: creator } = createUser(testDb, { email: 'creator2@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by) VALUES ('tok-expired', 5, 0, '2000-01-01T00:00:00.000Z', ?)"
).run(creator.id);
const result = findOrCreateUser(
{ sub: 'sub-expired-invite', email: 'expired-invitee@example.com', name: 'ExpiredInvitee' },
MOCK_CONFIG,
'tok-expired'
);
// User is still created because open registration is allowed
expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'expired-invitee@example.com'").get();
expect(newUser).toBeDefined();
// Invite used_count must remain 0 (token was treated as invalid)
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-expired'").get() as any;
expect(token.used_count).toBe(0);
});
it('OIDC-SVC-029: new user with max_uses exceeded invite token is created but invite is ignored', () => {
const { user: creator } = createUser(testDb, { email: 'creator3@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-full', 1, 1, ?)"
).run(creator.id);
const result = findOrCreateUser(
{ sub: 'sub-full-invite', email: 'full-invitee@example.com', name: 'FullInvitee' },
MOCK_CONFIG,
'tok-full'
);
// User is still created because open registration is allowed
expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'full-invitee@example.com'").get();
expect(newUser).toBeDefined();
// Invite used_count must remain 1 (token was treated as invalid)
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-full'").get() as any;
expect(token.used_count).toBe(1);
});
});
@@ -0,0 +1,255 @@
/**
* Unit tests for packingService.ts uncovered functions.
* Covers PACK-SVC-001 to PACK-SVC-012.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB mock setup (vi.hoisted so it is available before vi.mock calls) ────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import {
saveAsTemplate,
applyTemplate,
setBagMembers,
createBag,
deleteBag,
bulkImport,
} from '../../../src/services/packingService';
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── saveAsTemplate ────────────────────────────────────────────────────────────
describe('saveAsTemplate', () => {
it('PACK-SVC-001: saves packing items as a template with correct categories and item count', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Shirt', 'Clothes', 0);
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Shorts', 'Clothes', 1);
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Toothbrush', 'Toiletries', 2);
const result = saveAsTemplate(trip.id, user.id, 'My Template');
expect(result).not.toBeNull();
expect(result!.name).toBe('My Template');
expect(result!.categoryCount).toBe(2);
expect(result!.itemCount).toBe(3);
const template = testDb.prepare('SELECT * FROM packing_templates WHERE id = ?').get(result!.id) as any;
expect(template).toBeDefined();
expect(template.name).toBe('My Template');
expect(template.created_by).toBe(user.id);
});
it('PACK-SVC-002: returns null when trip has no packing items', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = saveAsTemplate(trip.id, user.id, 'Empty');
expect(result).toBeNull();
});
});
// ── applyTemplate ─────────────────────────────────────────────────────────────
describe('applyTemplate', () => {
it('PACK-SVC-003: adds template items to a trip packing list', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Insert a template with one category and two items directly
const templateResult = testDb.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run('Camping', user.id);
const templateId = templateResult.lastInsertRowid as number;
const catResult = testDb.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)').run(templateId, 'Gear', 0);
const catId = catResult.lastInsertRowid as number;
testDb.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, 'Tent', 0);
testDb.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, 'Sleeping Bag', 1);
const result = applyTemplate(trip.id, templateId);
expect(result).not.toBeNull();
expect(Array.isArray(result)).toBe(true);
expect((result as any[]).length).toBe(2);
const items = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(trip.id) as any[];
expect(items.length).toBe(2);
expect(items.map((i: any) => i.name)).toContain('Tent');
expect(items.map((i: any) => i.name)).toContain('Sleeping Bag');
});
it('PACK-SVC-004: returns null when template has no items', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const templateResult = testDb.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run('Empty Template', user.id);
const templateId = templateResult.lastInsertRowid as number;
const result = applyTemplate(trip.id, templateId);
expect(result).toBeNull();
});
});
// ── createBag / deleteBag ─────────────────────────────────────────────────────
describe('createBag / deleteBag', () => {
it('PACK-SVC-005: createBag inserts a bag and returns it', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = createBag(trip.id, { name: 'Carry-On', color: '#ff0000' }) as any;
expect(result).not.toBeNull();
expect(result.name).toBe('Carry-On');
expect(result.color).toBe('#ff0000');
expect(result.trip_id).toBe(trip.id);
const bag = testDb.prepare('SELECT * FROM packing_bags WHERE id = ?').get(result.id) as any;
expect(bag).toBeDefined();
expect(bag.name).toBe('Carry-On');
});
it('PACK-SVC-006: deleteBag removes the bag and returns true', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const bag = createBag(trip.id, { name: 'Checked Bag' }) as any;
expect(bag).not.toBeNull();
const deleted = deleteBag(trip.id, bag.id);
expect(deleted).toBe(true);
const row = testDb.prepare('SELECT * FROM packing_bags WHERE id = ?').get(bag.id);
expect(row).toBeUndefined();
});
it('PACK-SVC-007: deleteBag returns false for non-existent bag', () => {
const result = deleteBag(1, 99999);
expect(result).toBe(false);
});
});
// ── setBagMembers ─────────────────────────────────────────────────────────────
describe('setBagMembers', () => {
it('PACK-SVC-008: sets bag members (replaces existing)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const bag = createBag(trip.id, { name: 'Main Bag' }) as any;
const result = setBagMembers(trip.id, bag.id, [user.id]) as any[];
expect(result).not.toBeNull();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(1);
expect(result[0].user_id).toBe(user.id);
});
it('PACK-SVC-009: setBagMembers with empty array clears all members', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const bag = createBag(trip.id, { name: 'Main Bag' }) as any;
// First add a member
setBagMembers(trip.id, bag.id, [user.id]);
// Then clear
const result = setBagMembers(trip.id, bag.id, []) as any[];
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(0);
});
it('PACK-SVC-010: setBagMembers returns null for non-existent bag', () => {
const result = setBagMembers(1, 99999, []);
expect(result).toBeNull();
});
});
// ── bulkImport with bag field ─────────────────────────────────────────────────
describe('bulkImport with bag field', () => {
it('PACK-SVC-011: bulk import with bag field creates the bag if it does not exist', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = bulkImport(trip.id, [{ name: 'Shirt', bag: 'Carry-On' }]);
expect(result).toHaveLength(1);
expect(result[0]).toBeDefined();
const bags = testDb.prepare('SELECT * FROM packing_bags WHERE trip_id = ? AND name = ?').all(trip.id, 'Carry-On') as any[];
expect(bags).toHaveLength(1);
const items = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(trip.id) as any[];
expect(items).toHaveLength(1);
expect(items[0].bag_id).toBe(bags[0].id);
});
it('PACK-SVC-012: bulk import with same bag name reuses existing bag', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = bulkImport(trip.id, [
{ name: 'Shirt', bag: 'Carry-On' },
{ name: 'Pants', bag: 'Carry-On' },
]);
expect(result).toHaveLength(2);
const bags = testDb.prepare('SELECT * FROM packing_bags WHERE trip_id = ? AND name = ?').all(trip.id, 'Carry-On') as any[];
expect(bags).toHaveLength(1);
const items = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(trip.id) as any[];
expect(items).toHaveLength(2);
expect(items[0].bag_id).toBe(bags[0].id);
expect(items[1].bag_id).toBe(bags[0].id);
});
});
+33 -2
View File
@@ -1,16 +1,21 @@
import { describe, it, expect, vi } from 'vitest';
// Mutable rows array so individual tests can inject DB rows
const dbRows: { key: string; value: string }[] = [];
// Mock database — permissions module queries app_settings at runtime
vi.mock('../../../src/db/database', () => ({
db: {
prepare: () => ({
all: () => [], // no custom permissions → fall back to defaults
all: () => dbRows, // no custom permissions → fall back to defaults
run: vi.fn(),
get: vi.fn(),
}),
transaction: (fn: () => void) => fn,
},
}));
import { checkPermission, getPermissionLevel, PERMISSION_ACTIONS } from '../../../src/services/permissions';
import { checkPermission, getPermissionLevel, savePermissions, invalidatePermissionsCache, PERMISSION_ACTIONS } from '../../../src/services/permissions';
describe('permissions', () => {
describe('checkPermission — admin bypass', () => {
@@ -80,4 +85,30 @@ describe('permissions', () => {
expect(getPermissionLevel('nonexistent_action')).toBe('trip_owner');
});
});
describe('savePermissions — invalid action key is skipped', () => {
it('returns skipped array containing invalid action key', () => {
const result = savePermissions({ nonexistent_action: 'trip_member' });
expect(result.skipped).toContain('nonexistent_action');
});
it('returns skipped array when level is not in allowedLevels for the action', () => {
// trip_delete only allows ['admin', 'trip_owner'], so 'trip_member' is invalid
const result = savePermissions({ trip_delete: 'trip_member' });
expect(result.skipped).toContain('trip_delete');
});
});
describe('checkPermission — default case', () => {
it('returns false when permission level is an unrecognized value', () => {
// Inject a DB row with an unknown level for trip_edit, then invalidate cache
dbRows.push({ key: 'perm_trip_edit', value: 'unknown_level' });
invalidatePermissionsCache();
const result = checkPermission('trip_edit', 'user', 10, 10, false);
// Clean up for subsequent tests
dbRows.length = 0;
invalidatePermissionsCache();
expect(result).toBe(false);
});
});
});
@@ -0,0 +1,451 @@
/**
* Unit tests for placeService PLACE-SVC-001 through PLACE-SVC-025.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
* Skips importGpx / importGoogleList / searchPlaceImage (require external I/O).
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: any) => {
const place: any = db.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);
if (!place) return null;
const tags = db.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: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createPlace, createCategory, createTag } from '../../helpers/factories';
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, deletePlace, importGpx, importGoogleList, searchPlaceImage } from '../../../src/services/placeService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── listPlaces ────────────────────────────────────────────────────────────────
describe('listPlaces', () => {
it('PLACE-SVC-001 — returns empty array when trip has no places', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(listPlaces(String(trip.id), {})).toEqual([]);
});
it('PLACE-SVC-002 — returns all places for a trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createPlace(testDb, trip.id, { name: 'Alpha' });
createPlace(testDb, trip.id, { name: 'Beta' });
const places = listPlaces(String(trip.id), {}) as any[];
expect(places).toHaveLength(2);
});
it('PLACE-SVC-003 — does not return places from other trips', () => {
const { user } = createUser(testDb);
const t1 = createTrip(testDb, user.id);
const t2 = createTrip(testDb, user.id);
createPlace(testDb, t1.id, { name: 'T1 Place' });
createPlace(testDb, t2.id, { name: 'T2 Place' });
const places = listPlaces(String(t1.id), {}) as any[];
expect(places).toHaveLength(1);
expect(places[0].name).toBe('T1 Place');
});
it('PLACE-SVC-004 — filters by search term (name)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
createPlace(testDb, trip.id, { name: 'Louvre Museum' });
const places = listPlaces(String(trip.id), { search: 'Eiffel' }) as any[];
expect(places).toHaveLength(1);
expect(places[0].name).toBe('Eiffel Tower');
});
it('PLACE-SVC-005 — attaches tags array to each place (empty when none)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createPlace(testDb, trip.id, { name: 'No Tags' });
const places = listPlaces(String(trip.id), {}) as any[];
expect(Array.isArray(places[0].tags)).toBe(true);
expect(places[0].tags).toHaveLength(0);
});
it('PLACE-SVC-006 — attaches category object when place has a category', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const cat = createCategory(testDb, { name: 'Museum', user_id: user.id }) as any;
const place = createPlace(testDb, trip.id, { name: 'Art Museum' }) as any;
testDb.prepare('UPDATE places SET category_id = ? WHERE id = ?').run(cat.id, place.id);
const places = listPlaces(String(trip.id), {}) as any[];
expect(places[0].category).toBeDefined();
expect(places[0].category.name).toBe('Museum');
});
});
// ── createPlace (via service) ─────────────────────────────────────────────────
describe('createPlace (service)', () => {
it('PLACE-SVC-007 — creates a place and returns it with tags array', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = svcCreatePlace(String(trip.id), { name: 'New Place', lat: 48.8, lng: 2.3 }) as any;
expect(place).toBeDefined();
expect(place.name).toBe('New Place');
expect(Array.isArray(place.tags)).toBe(true);
});
it('PLACE-SVC-008 — creates a place with tags', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tag = createTag(testDb, user.id, { name: 'Highlight' }) as any;
const place = svcCreatePlace(String(trip.id), { name: 'Tagged Place', tags: [tag.id] }) as any;
expect(place.tags).toHaveLength(1);
expect(place.tags[0].id).toBe(tag.id);
});
it('PLACE-SVC-009 — place is associated with correct trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = svcCreatePlace(String(trip.id), { name: 'My Place' }) as any;
const row = testDb.prepare('SELECT trip_id FROM places WHERE id = ?').get(place.id) as any;
expect(row.trip_id).toBe(trip.id);
});
});
// ── getPlace ──────────────────────────────────────────────────────────────────
describe('getPlace', () => {
it('PLACE-SVC-010 — returns the place when tripId and placeId match', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Find Me' }) as any;
const found = getPlace(String(trip.id), String(place.id)) as any;
expect(found).toBeDefined();
expect(found.name).toBe('Find Me');
});
it('PLACE-SVC-011 — returns null when place belongs to different trip', () => {
const { user } = createUser(testDb);
const t1 = createTrip(testDb, user.id);
const t2 = createTrip(testDb, user.id);
const place = createPlace(testDb, t1.id, { name: 'T1 Place' }) as any;
expect(getPlace(String(t2.id), String(place.id))).toBeNull();
});
it('PLACE-SVC-012 — returns null for non-existent placeId', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(getPlace(String(trip.id), '99999')).toBeNull();
});
});
// ── updatePlace ───────────────────────────────────────────────────────────────
describe('updatePlace', () => {
it('PLACE-SVC-013 — updates place name and lat/lng', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Old', lat: 0, lng: 0 }) as any;
const updated = updatePlace(String(trip.id), String(place.id), { name: 'New', lat: 48.8, lng: 2.3 }) as any;
expect(updated.name).toBe('New');
expect(updated.lat).toBe(48.8);
expect(updated.lng).toBe(2.3);
});
it('PLACE-SVC-014 — returns null for non-existent place', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(updatePlace(String(trip.id), '99999', { name: 'Ghost' })).toBeNull();
});
it('PLACE-SVC-015 — updates tags (replaces old set)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tag1 = createTag(testDb, user.id, { name: 'Old Tag' }) as any;
const tag2 = createTag(testDb, user.id, { name: 'New Tag' }) as any;
const place = svcCreatePlace(String(trip.id), { name: 'Taggable', tags: [tag1.id] }) as any;
const updated = updatePlace(String(trip.id), String(place.id), { tags: [tag2.id] }) as any;
expect(updated.tags).toHaveLength(1);
expect(updated.tags[0].id).toBe(tag2.id);
});
it('PLACE-SVC-016 — clears tags when tags: [] is passed', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tag = createTag(testDb, user.id, { name: 'Temp' }) as any;
const place = svcCreatePlace(String(trip.id), { name: 'Untaggable', tags: [tag.id] }) as any;
const updated = updatePlace(String(trip.id), String(place.id), { tags: [] }) as any;
expect(updated.tags).toHaveLength(0);
});
});
// ── deletePlace ───────────────────────────────────────────────────────────────
describe('deletePlace', () => {
it('PLACE-SVC-017 — deletes a place and returns true', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'To Delete' }) as any;
expect(deletePlace(String(trip.id), String(place.id))).toBe(true);
expect(getPlace(String(trip.id), String(place.id))).toBeNull();
});
it('PLACE-SVC-018 — returns false for non-existent place', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(deletePlace(String(trip.id), '99999')).toBe(false);
});
it('PLACE-SVC-019 — deleting one place does not remove others', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const p1 = createPlace(testDb, trip.id, { name: 'Keep' }) as any;
const p2 = createPlace(testDb, trip.id, { name: 'Remove' }) as any;
deletePlace(String(trip.id), String(p2.id));
const remaining = listPlaces(String(trip.id), {}) as any[];
expect(remaining).toHaveLength(1);
expect(remaining[0].id).toBe(p1.id);
});
});
// ── importGpx ─────────────────────────────────────────────────────────────────
describe('importGpx', () => {
it('PLACE-SVC-020 — returns null when buffer has no <gpx> root', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = importGpx(String(trip.id), Buffer.from('<not-gpx/>'));
expect(result).toBeNull();
});
it('PLACE-SVC-021 — imports <wpt> waypoints as places', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
<wpt lat="48.8566" lon="2.3522"><name>Paris</name></wpt>
<wpt lat="51.5074" lon="-0.1278"><name>London</name></wpt>
</gpx>`);
const places = importGpx(String(trip.id), gpx) as any[];
expect(places).toHaveLength(2);
expect(places[0].name).toBe('Paris');
expect(places[1].name).toBe('London');
});
it('PLACE-SVC-022 — falls back to <rte> route points when no <wpt> elements exist', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
<rte>
<rtept lat="48.8566" lon="2.3522"><name>Start</name></rtept>
<rtept lat="51.5074" lon="-0.1278"><name>End</name></rtept>
</rte>
</gpx>`);
const places = importGpx(String(trip.id), gpx) as any[];
expect(places).toHaveLength(2);
expect(places[0].name).toBe('Start');
expect(places[1].name).toBe('End');
});
it('PLACE-SVC-023 — imports <trk> track as a single place with routeGeometry', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
<trk>
<name>My Track</name>
<trkseg>
<trkpt lat="48.8566" lon="2.3522"><ele>100</ele></trkpt>
<trkpt lat="48.8570" lon="2.3530"><ele>102</ele></trkpt>
</trkseg>
</trk>
</gpx>`);
const places = importGpx(String(trip.id), gpx) as any[];
expect(places).toHaveLength(1);
expect(places[0].name).toBe('My Track');
const geometry = JSON.parse(places[0].route_geometry);
expect(Array.isArray(geometry)).toBe(true);
expect(geometry).toHaveLength(2);
});
it('PLACE-SVC-024 — <wpt> and <trk> together: waypoints plus track appended', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
<wpt lat="48.8566" lon="2.3522"><name>POI</name></wpt>
<trk>
<name>Track</name>
<trkseg>
<trkpt lat="48.8566" lon="2.3522"></trkpt>
<trkpt lat="48.8570" lon="2.3530"></trkpt>
</trkseg>
</trk>
</gpx>`);
const places = importGpx(String(trip.id), gpx) as any[];
// 1 wpt + 1 trk
expect(places).toHaveLength(2);
const trackPlace = places.find((p: any) => p.name === 'Track') as any;
expect(trackPlace).toBeDefined();
const geometry = JSON.parse(trackPlace.route_geometry);
expect(geometry).toHaveLength(2);
});
it('PLACE-SVC-025 — returns null when GPX has no usable elements', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1"></gpx>`);
const result = importGpx(String(trip.id), gpx);
expect(result).toBeNull();
});
});
// ── importGoogleList ──────────────────────────────────────────────────────────
describe('importGoogleList', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('PLACE-SVC-026 — returns error when list ID cannot be extracted from URL', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = await importGoogleList(String(trip.id), 'https://example.com/no-id-here') as any;
expect(result.error).toMatch(/Could not extract list ID/);
expect(result.status).toBe(400);
});
it('PLACE-SVC-027 — returns error when Google Maps API responds with non-ok status', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, text: async () => '', status: 502 }));
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
const result = await importGoogleList(String(trip.id), url) as any;
expect(result.error).toMatch(/Failed to fetch list/);
expect(result.status).toBe(502);
});
it('PLACE-SVC-028 — imports places from a valid Google Maps list response', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const listPayload = [
[null, null, null, null, 'My Test List', null, null, null, [
[null, [null, null, null, null, null, [null, null, 48.8566, 2.3522]], 'Paris', null],
[null, [null, null, null, null, null, [null, null, 51.5074, -0.1278]], 'London', 'Great city'],
]],
];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => 'prefix\n' + JSON.stringify(listPayload),
}));
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
const result = await importGoogleList(String(trip.id), url) as any;
expect(result.listName).toBe('My Test List');
expect(result.places).toHaveLength(2);
expect(result.places[0].name).toBe('Paris');
expect(result.places[1].name).toBe('London');
});
it('PLACE-SVC-029 — returns error when list items array is empty', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const listPayload = [[null, null, null, null, 'Empty List', null, null, null, []]];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => 'prefix\n' + JSON.stringify(listPayload),
}));
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
const result = await importGoogleList(String(trip.id), url) as any;
expect(result.error).toBeDefined();
expect(result.status).toBe(400);
});
});
// ── searchPlaceImage ──────────────────────────────────────────────────────────
describe('searchPlaceImage', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('PLACE-SVC-030 — returns 404 when place does not exist', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = await searchPlaceImage(String(trip.id), '99999', user.id) as any;
expect(result.error).toBeDefined();
expect(result.status).toBe(404);
});
it('PLACE-SVC-031 — returns 400 when user has no Unsplash API key', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any;
const result = await searchPlaceImage(String(trip.id), String(place.id), user.id) as any;
expect(result.error).toMatch(/No Unsplash API key/);
expect(result.status).toBe(400);
});
it('PLACE-SVC-032 — returns photos when Unsplash API responds successfully', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any;
testDb.prepare('UPDATE users SET unsplash_api_key = ? WHERE id = ?').run('test-unsplash-key', user.id);
const mockPhotos = [
{ id: 'photo1', urls: { regular: 'https://img.example.com/1', thumb: 'https://img.example.com/t1' }, description: 'Tower', user: { name: 'Photographer' }, links: { html: 'https://unsplash.com/1' } },
];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ results: mockPhotos }),
status: 200,
}));
const result = await searchPlaceImage(String(trip.id), String(place.id), user.id) as any;
expect(result.photos).toHaveLength(1);
expect(result.photos[0].id).toBe('photo1');
expect(result.photos[0].url).toBe('https://img.example.com/1');
expect(result.photos[0].photographer).toBe('Photographer');
});
});
@@ -48,17 +48,6 @@ const sampleParticipants: Participant[] = [
];
describe('formatAssignmentWithPlace', () => {
it('returns correct top-level shape', () => {
const result = formatAssignmentWithPlace(makeRow(), sampleTags, sampleParticipants);
expect(result).toHaveProperty('id', 1);
expect(result).toHaveProperty('day_id', 10);
expect(result).toHaveProperty('order_index', 0);
expect(result).toHaveProperty('notes', 'assignment note');
expect(result).toHaveProperty('created_at');
expect(result).toHaveProperty('place');
expect(result).toHaveProperty('participants');
});
it('nests place fields correctly from flat row', () => {
const result = formatAssignmentWithPlace(makeRow(), [], []);
const { place } = result;
@@ -100,24 +89,4 @@ describe('formatAssignmentWithPlace', () => {
const result = formatAssignmentWithPlace(makeRow({ category_id: 0 as any }), [], []);
expect(result.place.category).toBeNull();
});
it('includes provided tags in place.tags', () => {
const result = formatAssignmentWithPlace(makeRow(), sampleTags, []);
expect(result.place.tags).toEqual(sampleTags);
});
it('defaults place.tags to [] when empty array provided', () => {
const result = formatAssignmentWithPlace(makeRow(), [], []);
expect(result.place.tags).toEqual([]);
});
it('includes provided participants', () => {
const result = formatAssignmentWithPlace(makeRow(), [], sampleParticipants);
expect(result.participants).toEqual(sampleParticipants);
});
it('defaults participants to [] when empty array provided', () => {
const result = formatAssignmentWithPlace(makeRow(), [], []);
expect(result.participants).toEqual([]);
});
});
@@ -0,0 +1,224 @@
/**
* Unit tests for settingsService SET-SVC-001 through SET-SVC-020.
* Uses a real in-memory SQLite DB; apiKeyCrypto is mocked to a passthrough
* so we don't need real encryption for most tests.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB + apiKeyCrypto mock ────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
// Passthrough crypto — value comes back unchanged for most tests
vi.mock('../../../src/services/apiKeyCrypto', () => ({
maybe_encrypt_api_key: (v: string) => v,
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { getUserSettings, upsertSetting, bulkUpsertSettings } from '../../../src/services/settingsService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── getUserSettings ───────────────────────────────────────────────────────────
describe('getUserSettings', () => {
it('SET-SVC-001 — returns empty object when user has no settings', () => {
const { user } = createUser(testDb);
expect(getUserSettings(user.id)).toEqual({});
});
it('SET-SVC-002 — returns stored plain string values', () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(user.id);
const s = getUserSettings(user.id);
expect(s.theme).toBe('dark');
});
it('SET-SVC-003 — JSON-parses values that are valid JSON', () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'count', '42')").run(user.id);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'flag', 'true')").run(user.id);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'obj', '{\"x\":1}')").run(user.id);
const s = getUserSettings(user.id);
expect(s.count).toBe(42);
expect(s.flag).toBe(true);
expect(s.obj).toEqual({ x: 1 });
});
it('SET-SVC-004 — falls back to raw string when value is not valid JSON', () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'raw', 'not-json')").run(user.id);
const s = getUserSettings(user.id);
expect(s.raw).toBe('not-json');
});
it('SET-SVC-005 — webhook_url with a value is masked as ••••••••', () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', 'https://secret.example.com')").run(user.id);
const s = getUserSettings(user.id);
expect(s.webhook_url).toBe('••••••••');
});
it('SET-SVC-006 — webhook_url with empty value returns empty string', () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', '')").run(user.id);
const s = getUserSettings(user.id);
expect(s.webhook_url).toBe('');
});
it('SET-SVC-007 — only returns settings for the requesting user', () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'key_a', '\"a\"')").run(a.id);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'key_b', '\"b\"')").run(b.id);
const s = getUserSettings(a.id);
expect(s).toHaveProperty('key_a');
expect(s).not.toHaveProperty('key_b');
});
});
// ── upsertSetting ─────────────────────────────────────────────────────────────
describe('upsertSetting', () => {
it('SET-SVC-008 — inserts a new setting', () => {
const { user } = createUser(testDb);
upsertSetting(user.id, 'language', 'en');
const s = getUserSettings(user.id);
expect(s.language).toBe('en');
});
it('SET-SVC-009 — updates an existing setting (ON CONFLICT)', () => {
const { user } = createUser(testDb);
upsertSetting(user.id, 'language', 'en');
upsertSetting(user.id, 'language', 'fr');
const s = getUserSettings(user.id);
expect(s.language).toBe('fr');
});
it('SET-SVC-010 — serializes object values as JSON', () => {
const { user } = createUser(testDb);
upsertSetting(user.id, 'prefs', { dark: true, size: 14 });
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'prefs'").get(user.id) as any;
expect(raw.value).toBe('{"dark":true,"size":14}');
});
it('SET-SVC-011 — serializes boolean values as strings', () => {
const { user } = createUser(testDb);
upsertSetting(user.id, 'notifications', true);
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'notifications'").get(user.id) as any;
expect(raw.value).toBe('true');
});
it('SET-SVC-012 — webhook_url passes through maybe_encrypt_api_key', () => {
const { user } = createUser(testDb);
upsertSetting(user.id, 'webhook_url', 'https://hook.example.com');
// With passthrough mock, value is stored as-is
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(user.id) as any;
expect(raw.value).toBe('https://hook.example.com');
// But getUserSettings masks it
const s = getUserSettings(user.id);
expect(s.webhook_url).toBe('••••••••');
});
});
// ── bulkUpsertSettings ────────────────────────────────────────────────────────
describe('bulkUpsertSettings', () => {
it('SET-SVC-013 — inserts multiple settings in one call', () => {
const { user } = createUser(testDb);
bulkUpsertSettings(user.id, { a: 'alpha', b: 'beta', c: 'gamma' });
const s = getUserSettings(user.id);
expect(s.a).toBe('alpha');
expect(s.b).toBe('beta');
expect(s.c).toBe('gamma');
});
it('SET-SVC-014 — returns the count of settings processed', () => {
const { user } = createUser(testDb);
const count = bulkUpsertSettings(user.id, { x: 1, y: 2, z: 3 });
expect(count).toBe(3);
});
it('SET-SVC-015 — updates existing keys (ON CONFLICT)', () => {
const { user } = createUser(testDb);
upsertSetting(user.id, 'theme', 'light');
bulkUpsertSettings(user.id, { theme: 'dark', lang: 'en' });
const s = getUserSettings(user.id);
expect(s.theme).toBe('dark');
expect(s.lang).toBe('en');
});
it('SET-SVC-016 — returns 0 for empty settings object', () => {
const { user } = createUser(testDb);
const count = bulkUpsertSettings(user.id, {});
expect(count).toBe(0);
});
it('SET-SVC-017 — all changes are committed atomically (transaction)', () => {
const { user } = createUser(testDb);
bulkUpsertSettings(user.id, { p: '1', q: '2' });
const rows = testDb.prepare('SELECT key FROM settings WHERE user_id = ?').all(user.id) as any[];
const keys = rows.map((r: any) => r.key);
expect(keys).toContain('p');
expect(keys).toContain('q');
});
it('SET-SVC-018 — settings from different users do not interfere', () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
bulkUpsertSettings(a.id, { shared_key: 'from-a' });
bulkUpsertSettings(b.id, { shared_key: 'from-b' });
expect((getUserSettings(a.id) as any).shared_key).toBe('from-a');
expect((getUserSettings(b.id) as any).shared_key).toBe('from-b');
});
it('SET-SVC-019 — rolls back and re-throws when DB write fails mid-transaction', () => {
const { user } = createUser(testDb);
const origPrepare = testDb.prepare.bind(testDb);
let intercepted = false;
vi.spyOn(testDb, 'prepare').mockImplementationOnce((sql: string) => {
const stmt = origPrepare(sql);
intercepted = true;
return { run: () => { throw new Error('forced DB error'); } } as any;
});
expect(() => bulkUpsertSettings(user.id, { k: 'v' })).toThrow('forced DB error');
expect(intercepted).toBe(true);
vi.restoreAllMocks();
});
});
@@ -0,0 +1,180 @@
/**
* Unit tests for tagService TAG-SVC-001 through TAG-SVC-015.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { listTags, createTag, getTagByIdAndUser, updateTag, deleteTag } from '../../../src/services/tagService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── listTags ──────────────────────────────────────────────────────────────────
describe('listTags', () => {
it('TAG-SVC-001 — returns empty array when user has no tags', () => {
const { user } = createUser(testDb);
expect(listTags(user.id)).toEqual([]);
});
it('TAG-SVC-002 — returns only tags belonging to the user', () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
createTag(a.id, 'A-Tag');
createTag(b.id, 'B-Tag');
const tags = listTags(a.id) as any[];
expect(tags).toHaveLength(1);
expect(tags[0].name).toBe('A-Tag');
});
it('TAG-SVC-003 — results are ordered by name ascending', () => {
const { user } = createUser(testDb);
createTag(user.id, 'Zebra');
createTag(user.id, 'Apple');
createTag(user.id, 'Mango');
const names = (listTags(user.id) as any[]).map((t: any) => t.name);
expect(names).toEqual(['Apple', 'Mango', 'Zebra']);
});
});
// ── createTag ─────────────────────────────────────────────────────────────────
describe('createTag', () => {
it('TAG-SVC-004 — creates a tag with provided name and color', () => {
const { user } = createUser(testDb);
const tag = createTag(user.id, 'Beach', '#ff0000') as any;
expect(tag.name).toBe('Beach');
expect(tag.color).toBe('#ff0000');
expect(tag.user_id).toBe(user.id);
});
it('TAG-SVC-005 — defaults to #10b981 when no color provided', () => {
const { user } = createUser(testDb);
const tag = createTag(user.id, 'Default') as any;
expect(tag.color).toBe('#10b981');
});
it('TAG-SVC-006 — returns the inserted row with an id', () => {
const { user } = createUser(testDb);
const tag = createTag(user.id, 'WithId') as any;
expect(typeof tag.id).toBe('number');
expect(tag.id).toBeGreaterThan(0);
});
});
// ── getTagByIdAndUser ─────────────────────────────────────────────────────────
describe('getTagByIdAndUser', () => {
it('TAG-SVC-007 — returns the tag when id and user_id match', () => {
const { user } = createUser(testDb);
const created = createTag(user.id, 'Find Me') as any;
const found = getTagByIdAndUser(created.id, user.id) as any;
expect(found).toBeDefined();
expect(found.name).toBe('Find Me');
});
it('TAG-SVC-008 — returns undefined when tag belongs to different user', () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
const tag = createTag(a.id, 'Private') as any;
expect(getTagByIdAndUser(tag.id, b.id)).toBeUndefined();
});
it('TAG-SVC-009 — returns undefined for non-existent tag id', () => {
const { user } = createUser(testDb);
expect(getTagByIdAndUser(99999, user.id)).toBeUndefined();
});
});
// ── updateTag ─────────────────────────────────────────────────────────────────
describe('updateTag', () => {
it('TAG-SVC-010 — updates both name and color', () => {
const { user } = createUser(testDb);
const tag = createTag(user.id, 'Old', '#aaaaaa') as any;
const updated = updateTag(tag.id, 'New', '#bbbbbb') as any;
expect(updated.name).toBe('New');
expect(updated.color).toBe('#bbbbbb');
});
it('TAG-SVC-011 — COALESCE: omitting name preserves existing name', () => {
const { user } = createUser(testDb);
const tag = createTag(user.id, 'KeepMe', '#aaaaaa') as any;
const updated = updateTag(tag.id, undefined, '#cccccc') as any;
expect(updated.name).toBe('KeepMe');
expect(updated.color).toBe('#cccccc');
});
it('TAG-SVC-012 — COALESCE: omitting color preserves existing color', () => {
const { user } = createUser(testDb);
const tag = createTag(user.id, 'ColorKeep', '#dddddd') as any;
const updated = updateTag(tag.id, 'NewName', undefined) as any;
expect(updated.name).toBe('NewName');
expect(updated.color).toBe('#dddddd');
});
});
// ── deleteTag ─────────────────────────────────────────────────────────────────
describe('deleteTag', () => {
it('TAG-SVC-013 — deletes the tag from the database', () => {
const { user } = createUser(testDb);
const tag = createTag(user.id, 'ToDelete') as any;
deleteTag(tag.id);
expect(getTagByIdAndUser(tag.id, user.id)).toBeUndefined();
});
it('TAG-SVC-014 — deleting a non-existent tag does not throw', () => {
expect(() => deleteTag(99999)).not.toThrow();
});
it('TAG-SVC-015 — deleting one tag does not affect other tags', () => {
const { user } = createUser(testDb);
const t1 = createTag(user.id, 'Keep') as any;
const t2 = createTag(user.id, 'Remove') as any;
deleteTag(t2.id);
const remaining = listTags(user.id) as any[];
expect(remaining).toHaveLength(1);
expect(remaining[0].id).toBe(t1.id);
});
});
@@ -0,0 +1,287 @@
/**
* Unit tests for todoService TODO-SVC-001 through TODO-SVC-020.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.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: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
import {
verifyTripAccess,
listItems,
createItem,
updateItem,
deleteItem,
getCategoryAssignees,
updateCategoryAssignees,
reorderItems,
} from '../../../src/services/todoService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── verifyTripAccess ──────────────────────────────────────────────────────────
describe('verifyTripAccess', () => {
it('TODO-SVC-001: returns trip for owner', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = verifyTripAccess(trip.id, user.id);
expect(result).toBeDefined();
expect((result as any).id).toBe(trip.id);
});
it('TODO-SVC-002: returns null for non-member', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
expect(verifyTripAccess(trip.id, stranger.id)).toBeFalsy();
});
it('TODO-SVC-003: returns trip for member', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const result = verifyTripAccess(trip.id, member.id);
expect(result).toBeDefined();
});
});
// ── listItems / createItem ────────────────────────────────────────────────────
describe('listItems and createItem', () => {
it('TODO-SVC-004: listItems returns empty array for new trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(listItems(trip.id)).toEqual([]);
});
it('TODO-SVC-005: createItem inserts a todo with name only', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createItem(trip.id, { name: 'Buy snacks' }) as any;
expect(item).toBeDefined();
expect(item.name).toBe('Buy snacks');
expect(item.checked).toBe(0);
expect(item.trip_id).toBe(trip.id);
expect(item.sort_order).toBe(0);
});
it('TODO-SVC-006: createItem assigns incrementing sort_order', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const a = createItem(trip.id, { name: 'A' }) as any;
const b = createItem(trip.id, { name: 'B' }) as any;
expect(b.sort_order).toBe(a.sort_order + 1);
});
it('TODO-SVC-007: createItem stores optional fields', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createItem(trip.id, {
name: 'Pack bag',
category: 'Prep',
description: 'All the gear',
priority: 3,
}) as any;
expect(item.category).toBe('Prep');
expect(item.description).toBe('All the gear');
expect(item.priority).toBe(3);
});
it('TODO-SVC-008: listItems returns items ordered by sort_order', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createItem(trip.id, { name: 'First' });
createItem(trip.id, { name: 'Second' });
createItem(trip.id, { name: 'Third' });
const items = listItems(trip.id) as any[];
expect(items).toHaveLength(3);
expect(items[0].sort_order).toBeLessThanOrEqual(items[1].sort_order);
expect(items[1].sort_order).toBeLessThanOrEqual(items[2].sort_order);
});
});
// ── updateItem ────────────────────────────────────────────────────────────────
describe('updateItem', () => {
it('TODO-SVC-009: returns null for non-existent item', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(updateItem(trip.id, 99999, { name: 'Ghost' }, ['name'])).toBeNull();
});
it('TODO-SVC-010: toggles checked status', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createItem(trip.id, { name: 'Visit museum' }) as any;
const updated = updateItem(trip.id, item.id, { checked: 1 }, ['checked']) as any;
expect(updated.checked).toBe(1);
const back = updateItem(trip.id, item.id, { checked: 0 }, ['checked']) as any;
expect(back.checked).toBe(0);
});
it('TODO-SVC-011: updates name and category', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createItem(trip.id, { name: 'Old' }) as any;
const updated = updateItem(trip.id, item.id, { name: 'New', category: 'Misc' }, ['name', 'category']) as any;
expect(updated.name).toBe('New');
expect(updated.category).toBe('Misc');
});
it('TODO-SVC-012: clears due_date when key is present with null value', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createItem(trip.id, { name: 'Task', due_date: '2026-06-01' }) as any;
const updated = updateItem(trip.id, item.id, { due_date: null }, ['due_date']) as any;
expect(updated.due_date).toBeNull();
});
});
// ── deleteItem ────────────────────────────────────────────────────────────────
describe('deleteItem', () => {
it('TODO-SVC-013: returns false for non-existent item', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(deleteItem(trip.id, 99999)).toBe(false);
});
it('TODO-SVC-014: deletes item and returns true', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createItem(trip.id, { name: 'Gone' }) as any;
expect(deleteItem(trip.id, item.id)).toBe(true);
expect(listItems(trip.id)).toHaveLength(0);
});
});
// ── reorderItems ──────────────────────────────────────────────────────────────
describe('reorderItems', () => {
it('TODO-SVC-015: assigns sort_order matching orderedIds array position', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const a = createItem(trip.id, { name: 'A' }) as any;
const b = createItem(trip.id, { name: 'B' }) as any;
const c = createItem(trip.id, { name: 'C' }) as any;
reorderItems(trip.id, [c.id, a.id, b.id]);
const rows = testDb.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(trip.id) as any[];
expect(rows[0].id).toBe(c.id);
expect(rows[1].id).toBe(a.id);
expect(rows[2].id).toBe(b.id);
});
});
// ── category assignees ────────────────────────────────────────────────────────
describe('getCategoryAssignees / updateCategoryAssignees', () => {
it('TODO-SVC-016: returns empty object for new trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(getCategoryAssignees(trip.id)).toEqual({});
});
it('TODO-SVC-017: updateCategoryAssignees sets assignees for a category', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const rows = updateCategoryAssignees(trip.id, 'Packing', [owner.id, member.id]) as any[];
expect(rows).toHaveLength(2);
const assignees = getCategoryAssignees(trip.id) as any;
expect(assignees['Packing']).toHaveLength(2);
});
it('TODO-SVC-018: updateCategoryAssignees with empty array clears assignees', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
updateCategoryAssignees(trip.id, 'Packing', [owner.id]);
const cleared = updateCategoryAssignees(trip.id, 'Packing', []) as any[];
expect(cleared).toHaveLength(0);
const assignees = getCategoryAssignees(trip.id) as any;
expect(assignees['Packing']).toBeUndefined();
});
it('TODO-SVC-019: getCategoryAssignees groups by category name', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
updateCategoryAssignees(trip.id, 'Shopping', [owner.id]);
updateCategoryAssignees(trip.id, 'Logistics', [member.id]);
const assignees = getCategoryAssignees(trip.id) as any;
expect(Object.keys(assignees)).toHaveLength(2);
expect(assignees['Shopping']).toHaveLength(1);
expect(assignees['Logistics']).toHaveLength(1);
});
it('TODO-SVC-020: updateCategoryAssignees replaces existing assignees (not append)', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
updateCategoryAssignees(trip.id, 'Food', [owner.id, member.id]);
// Replace with just owner
updateCategoryAssignees(trip.id, 'Food', [owner.id]);
const assignees = getCategoryAssignees(trip.id) as any;
expect(assignees['Food']).toHaveLength(1);
expect(assignees['Food'][0].user_id).toBe(owner.id);
});
});
@@ -0,0 +1,179 @@
/**
* Unit tests for tripService exportICS function (TRIP-SVC-001 through TRIP-SVC-009).
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createReservation } from '../../helpers/factories';
import { exportICS } from '../../../src/services/tripService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('exportICS', () => {
it('TRIP-SVC-001: returns VCALENDAR wrapper', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, {
title: 'My Vacation',
start_date: '2025-06-01',
end_date: '2025-06-07',
});
const { ics } = exportICS(trip.id);
expect(ics).toContain('BEGIN:VCALENDAR');
expect(ics).toContain('END:VCALENDAR');
});
it('TRIP-SVC-002: trip with start_date + end_date includes all-day VEVENT', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, {
title: 'Summer Holiday',
start_date: '2025-06-01',
end_date: '2025-06-07',
});
const { ics } = exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20250601');
expect(ics).toContain('SUMMARY:Summer Holiday');
});
it('TRIP-SVC-003: reservation with full datetime (includes T) → DTSTART without VALUE=DATE', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'Morning Flight',
type: 'flight',
});
testDb
.prepare('UPDATE reservations SET reservation_time=? WHERE id=?')
.run('2025-06-02T09:00', reservation.id);
const { ics } = exportICS(trip.id);
expect(ics).toContain('DTSTART:20250602T090000');
expect(ics).not.toContain('DTSTART;VALUE=DATE');
});
it('TRIP-SVC-004: reservation with date-only → DTSTART;VALUE=DATE', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'Hotel Check-in',
type: 'hotel',
});
testDb
.prepare('UPDATE reservations SET reservation_time=? WHERE id=?')
.run('2025-06-02', reservation.id);
const { ics } = exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20250602');
});
it('TRIP-SVC-005: reservation metadata with flight info appears in DESCRIPTION', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'CDG to JFK',
type: 'flight',
});
testDb
.prepare('UPDATE reservations SET reservation_time=?, metadata=? WHERE id=?')
.run(
'2025-06-02T09:00',
JSON.stringify({
airline: 'Air Test',
flight_number: 'AT100',
departure_airport: 'CDG',
arrival_airport: 'JFK',
}),
reservation.id
);
const { ics } = exportICS(trip.id);
expect(ics).toContain('Airline: Air Test');
expect(ics).toContain('Flight: AT100');
});
it('TRIP-SVC-006: special characters in title are escaped', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip; First, Best' });
const { ics } = exportICS(trip.id);
expect(ics).toContain('Trip\\; First\\, Best');
});
it('TRIP-SVC-007: throws NotFoundError for non-existent trip', () => {
expect(() => exportICS(99999)).toThrow();
});
it('TRIP-SVC-008: returns a filename derived from trip title', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'My Trip 2025' });
const { filename } = exportICS(trip.id);
expect(filename).toMatch(/My.Trip.2025\.ics/);
});
it('TRIP-SVC-009: reservation with end time includes DTEND', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'Afternoon Tour',
type: 'activity',
});
testDb
.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2025-06-02T14:00', '2025-06-02T16:00', reservation.id);
const { ics } = exportICS(trip.id);
expect(ics).toContain('DTEND:20250602T160000');
});
});
@@ -0,0 +1,745 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup (real in-memory SQLite) ─────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
canAccessTrip: () => null,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
// Mock websocket so notifyPlanUsers doesn't throw
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import {
getOwnPlan,
getActivePlan,
getPlanUsers,
migrateHolidayCalendars,
updatePlan,
addHolidayCalendar,
updateHolidayCalendar,
deleteHolidayCalendar,
setUserColor,
acceptInvite,
declineInvite,
cancelInvite,
getAvailableUsers,
listYears,
addYear,
deleteYear,
getEntries,
toggleEntry,
toggleCompanyHoliday,
getStats,
applyHolidayCalendars,
} from '../../../src/services/vacayService';
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
// Stub fetch with empty holiday list by default so updatePlan / applyHolidayCalendars
// never makes real network calls.
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => [],
}));
});
afterAll(() => {
vi.unstubAllGlobals();
testDb.close();
});
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Insert a vacay_plan_members row directly (no service factory for it). */
function insertMember(planId: number, userId: number, status: 'pending' | 'accepted'): void {
testDb.prepare(
"INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)"
).run(planId, userId, status);
}
/** Fast helper: create a user and immediately materialise their own plan. */
function setupUserWithPlan() {
const { user } = createUser(testDb);
const plan = getOwnPlan(user.id);
return { user, plan };
}
// ── getOwnPlan ────────────────────────────────────────────────────────────────
describe('getOwnPlan', () => {
it('VACAY-SVC-001: creates a new plan on first call for a fresh user', () => {
const { user } = createUser(testDb);
const plan = getOwnPlan(user.id);
expect(plan).toBeDefined();
expect(plan.owner_id).toBe(user.id);
expect(plan.id).toBeGreaterThan(0);
});
it('VACAY-SVC-002: returns the same plan on a second call (idempotent)', () => {
const { user } = createUser(testDb);
const first = getOwnPlan(user.id);
const second = getOwnPlan(user.id);
expect(second.id).toBe(first.id);
});
it('VACAY-SVC-003: seeds the current year row in vacay_years after plan creation', () => {
const { user } = createUser(testDb);
const plan = getOwnPlan(user.id);
const yr = new Date().getFullYear();
const row = testDb
.prepare('SELECT * FROM vacay_years WHERE plan_id = ? AND year = ?')
.get(plan.id, yr);
expect(row).toBeDefined();
});
it('VACAY-SVC-004: seeds the current year user_year row with default 30 vacation_days', () => {
const { user } = createUser(testDb);
const plan = getOwnPlan(user.id);
const yr = new Date().getFullYear();
const row = testDb
.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?')
.get(user.id, plan.id, yr) as { vacation_days: number } | undefined;
expect(row).toBeDefined();
expect(row!.vacation_days).toBe(30);
});
});
// ── getActivePlan ─────────────────────────────────────────────────────────────
describe('getActivePlan', () => {
it('VACAY-SVC-005: returns own plan when user has no accepted membership in another plan', () => {
const { user, plan } = setupUserWithPlan();
const active = getActivePlan(user.id);
expect(active.id).toBe(plan.id);
expect(active.owner_id).toBe(user.id);
});
it('VACAY-SVC-006: returns the shared plan when user has an accepted membership in another plan', () => {
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
const { user: member } = createUser(testDb);
// Make sure member also has their own plan materialised first
getOwnPlan(member.id);
insertMember(ownerPlan.id, member.id, 'accepted');
const active = getActivePlan(member.id);
expect(active.id).toBe(ownerPlan.id);
});
it('VACAY-SVC-007: pending membership does NOT override own plan as active', () => {
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
const { user: member } = createUser(testDb);
getOwnPlan(member.id);
insertMember(ownerPlan.id, member.id, 'pending');
const active = getActivePlan(member.id);
// Should still point to member's own plan
expect(active.owner_id).toBe(member.id);
});
});
// ── getPlanUsers ──────────────────────────────────────────────────────────────
describe('getPlanUsers', () => {
it('VACAY-SVC-008: returns [owner] for a solo plan', () => {
const { user, plan } = setupUserWithPlan();
const users = getPlanUsers(plan.id);
expect(users).toHaveLength(1);
expect(users[0].id).toBe(user.id);
});
it('VACAY-SVC-009: returns [owner, member] after an accepted membership is inserted', () => {
const { user: owner, plan } = setupUserWithPlan();
const { user: member } = createUser(testDb);
insertMember(plan.id, member.id, 'accepted');
const users = getPlanUsers(plan.id);
expect(users).toHaveLength(2);
expect(users.map(u => u.id)).toContain(owner.id);
expect(users.map(u => u.id)).toContain(member.id);
});
it('VACAY-SVC-010: pending membership members are NOT included in plan users', () => {
const { plan } = setupUserWithPlan();
const { user: pendingUser } = createUser(testDb);
insertMember(plan.id, pendingUser.id, 'pending');
const users = getPlanUsers(plan.id);
expect(users.map(u => u.id)).not.toContain(pendingUser.id);
});
it('VACAY-SVC-011: returns empty array for a non-existent plan id', () => {
const users = getPlanUsers(99999);
expect(users).toEqual([]);
});
});
// ── migrateHolidayCalendars ───────────────────────────────────────────────────
describe('migrateHolidayCalendars', () => {
it('VACAY-SVC-012: does nothing when holidays_enabled is falsy', async () => {
const { plan } = setupUserWithPlan();
const planRow = { ...plan, holidays_enabled: 0, holidays_region: 'DE' };
await migrateHolidayCalendars(plan.id, planRow);
const rows = testDb
.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?')
.all(plan.id);
expect(rows).toHaveLength(0);
});
it('VACAY-SVC-013: inserts a calendar row when holidays_enabled=1 and holidays_region is set', async () => {
const { plan } = setupUserWithPlan();
const planRow = { ...plan, holidays_enabled: 1, holidays_region: 'DE' };
await migrateHolidayCalendars(plan.id, planRow);
const rows = testDb
.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?')
.all(plan.id) as { region: string }[];
expect(rows).toHaveLength(1);
expect(rows[0].region).toBe('DE');
});
it('VACAY-SVC-014: does nothing if a calendar row already exists (no duplicate)', async () => {
const { plan } = setupUserWithPlan();
const planRow = { ...plan, holidays_enabled: 1, holidays_region: 'FR' };
await migrateHolidayCalendars(plan.id, planRow);
// Call a second time — should NOT insert another row
await migrateHolidayCalendars(plan.id, planRow);
const rows = testDb
.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?')
.all(plan.id);
expect(rows).toHaveLength(1);
});
});
// ── updatePlan ────────────────────────────────────────────────────────────────
describe('updatePlan', () => {
it('VACAY-SVC-015: updates block_weekends flag', async () => {
const { plan } = setupUserWithPlan();
await updatePlan(plan.id, { block_weekends: true }, undefined);
const updated = testDb
.prepare('SELECT block_weekends FROM vacay_plans WHERE id = ?')
.get(plan.id) as { block_weekends: number };
expect(updated.block_weekends).toBe(1);
});
it('VACAY-SVC-016: updates holidays_enabled flag', async () => {
const { plan } = setupUserWithPlan();
await updatePlan(plan.id, { holidays_enabled: true }, undefined);
const updated = testDb
.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?')
.get(plan.id) as { holidays_enabled: number };
expect(updated.holidays_enabled).toBe(1);
});
it('VACAY-SVC-017: returns the updated plan object with boolean-coerced flags', async () => {
const { plan } = setupUserWithPlan();
const result = await updatePlan(plan.id, { block_weekends: false }, undefined);
expect(result.plan.block_weekends).toBe(false);
expect(typeof result.plan.holidays_enabled).toBe('boolean');
});
it('VACAY-SVC-018: resets carried_over to 0 for all user_years when carry_over_enabled is set to false', async () => {
const { user, plan } = setupUserWithPlan();
const yr = new Date().getFullYear();
// Manually set a non-zero carried_over value
testDb
.prepare('UPDATE vacay_user_years SET carried_over = 5 WHERE user_id = ? AND plan_id = ? AND year = ?')
.run(user.id, plan.id, yr);
await updatePlan(plan.id, { carry_over_enabled: false }, undefined);
const row = testDb
.prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?')
.get(user.id, plan.id, yr) as { carried_over: number };
expect(row.carried_over).toBe(0);
});
});
// ── addHolidayCalendar ────────────────────────────────────────────────────────
describe('addHolidayCalendar', () => {
it('VACAY-SVC-019: inserts a new calendar row and returns the calendar object', () => {
const { plan } = setupUserWithPlan();
const cal = addHolidayCalendar(plan.id, 'GB', 'UK Holidays', '#ff0000', 0, undefined);
expect(cal).toBeDefined();
expect(cal.id).toBeGreaterThan(0);
expect(cal.region).toBe('GB');
expect(cal.label).toBe('UK Holidays');
expect(cal.color).toBe('#ff0000');
});
it('VACAY-SVC-020: uses default color #fecaca when no color is provided', () => {
const { plan } = setupUserWithPlan();
const cal = addHolidayCalendar(plan.id, 'US', null, undefined, 0, undefined);
expect(cal.color).toBe('#fecaca');
});
});
// ── updateHolidayCalendar ─────────────────────────────────────────────────────
describe('updateHolidayCalendar', () => {
it('VACAY-SVC-021: changes label and color on an existing calendar', () => {
const { plan } = setupUserWithPlan();
const cal = addHolidayCalendar(plan.id, 'DE', 'Germany', '#aabbcc', 0, undefined);
const updated = updateHolidayCalendar(cal.id, plan.id, { label: 'Deutschland', color: '#112233' }, undefined);
expect(updated).not.toBeNull();
expect(updated!.label).toBe('Deutschland');
expect(updated!.color).toBe('#112233');
});
it('VACAY-SVC-022: returns null when the calendar id does not exist in the plan', () => {
const { plan } = setupUserWithPlan();
const result = updateHolidayCalendar(99999, plan.id, { label: 'Nope' }, undefined);
expect(result).toBeNull();
});
});
// ── deleteHolidayCalendar ─────────────────────────────────────────────────────
describe('deleteHolidayCalendar', () => {
it('VACAY-SVC-023: removes the calendar row and returns true on success', () => {
const { plan } = setupUserWithPlan();
const cal = addHolidayCalendar(plan.id, 'FR', null, undefined, 0, undefined);
const result = deleteHolidayCalendar(cal.id, plan.id, undefined);
expect(result).toBe(true);
const row = testDb.prepare('SELECT id FROM vacay_holiday_calendars WHERE id = ?').get(cal.id);
expect(row).toBeUndefined();
});
it('VACAY-SVC-024: returns false when the calendar does not exist', () => {
const { plan } = setupUserWithPlan();
const result = deleteHolidayCalendar(99999, plan.id, undefined);
expect(result).toBe(false);
});
});
// ── setUserColor ──────────────────────────────────────────────────────────────
describe('setUserColor', () => {
it('VACAY-SVC-025: inserts a color for a user in a plan', () => {
const { user, plan } = setupUserWithPlan();
setUserColor(user.id, plan.id, '#123456', undefined);
const row = testDb
.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?')
.get(user.id, plan.id) as { color: string } | undefined;
expect(row?.color).toBe('#123456');
});
it('VACAY-SVC-026: updates the color when called a second time (upsert)', () => {
const { user, plan } = setupUserWithPlan();
setUserColor(user.id, plan.id, '#aaaaaa', undefined);
setUserColor(user.id, plan.id, '#bbbbbb', undefined);
const row = testDb
.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?')
.get(user.id, plan.id) as { color: string };
expect(row.color).toBe('#bbbbbb');
});
});
// ── listYears / addYear / deleteYear ──────────────────────────────────────────
describe('listYears', () => {
it('VACAY-SVC-027: returns the seeded current year for a freshly created plan', () => {
const { plan } = setupUserWithPlan();
const yr = new Date().getFullYear();
const years = listYears(plan.id);
expect(years).toContain(yr);
});
});
describe('addYear', () => {
it('VACAY-SVC-028: inserts a new year and creates a user_year record', () => {
const { user, plan } = setupUserWithPlan();
const newYear = new Date().getFullYear() + 2;
addYear(plan.id, newYear, undefined);
const years = listYears(plan.id);
expect(years).toContain(newYear);
const userYear = testDb
.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?')
.get(user.id, plan.id, newYear) as { vacation_days: number } | undefined;
expect(userYear).toBeDefined();
expect(userYear!.vacation_days).toBe(30);
});
it('VACAY-SVC-029: carries over remaining days to the new year when carry_over_enabled is true', () => {
const { user, plan } = setupUserWithPlan();
const currentYear = new Date().getFullYear();
const nextYear = currentYear + 1;
// Enable carry-over and seed some entries for the current year
testDb.prepare('UPDATE vacay_plans SET carry_over_enabled = 1 WHERE id = ?').run(plan.id);
// Ensure current year row exists with 10 vacation days
testDb.prepare(`
INSERT OR REPLACE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over)
VALUES (?, ?, ?, 10, 0)
`).run(user.id, plan.id, currentYear);
// Add 3 entries (used days) in the current year
for (let day = 1; day <= 3; day++) {
const dateStr = `${currentYear}-06-0${day}`;
testDb.prepare('INSERT OR IGNORE INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(plan.id, user.id, dateStr, '');
}
addYear(plan.id, nextYear, undefined);
const userYear = testDb
.prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?')
.get(user.id, plan.id, nextYear) as { carried_over: number } | undefined;
// 10 vacation days - 3 used = 7 carried over
expect(userYear?.carried_over).toBe(7);
});
});
describe('deleteYear', () => {
it('VACAY-SVC-030: removes the year row and its associated entries', () => {
const { user, plan } = setupUserWithPlan();
const targetYear = new Date().getFullYear() + 3;
addYear(plan.id, targetYear, undefined);
// Insert an entry for that year
testDb
.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)')
.run(plan.id, user.id, `${targetYear}-07-15`, '');
deleteYear(plan.id, targetYear, undefined);
const yearRow = testDb
.prepare('SELECT * FROM vacay_years WHERE plan_id = ? AND year = ?')
.get(plan.id, targetYear);
expect(yearRow).toBeUndefined();
const entries = testDb
.prepare("SELECT * FROM vacay_entries WHERE plan_id = ? AND date LIKE ?")
.all(plan.id, `${targetYear}-%`);
expect(entries).toHaveLength(0);
});
});
// ── getEntries / toggleEntry ──────────────────────────────────────────────────
describe('getEntries', () => {
it('VACAY-SVC-031: returns empty entries and companyHolidays for a new plan+year', () => {
const { plan } = setupUserWithPlan();
const yr = new Date().getFullYear().toString();
const result = getEntries(plan.id, yr);
expect(result.entries).toEqual([]);
expect(result.companyHolidays).toEqual([]);
});
});
describe('toggleEntry', () => {
it('VACAY-SVC-032: adds an entry on first call (action: added)', () => {
const { user, plan } = setupUserWithPlan();
const result = toggleEntry(user.id, plan.id, '2025-08-01', undefined);
expect(result.action).toBe('added');
const row = testDb
.prepare('SELECT * FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?')
.get(user.id, plan.id, '2025-08-01');
expect(row).toBeDefined();
});
it('VACAY-SVC-033: removes the entry on second call (action: removed)', () => {
const { user, plan } = setupUserWithPlan();
toggleEntry(user.id, plan.id, '2025-08-02', undefined);
const result = toggleEntry(user.id, plan.id, '2025-08-02', undefined);
expect(result.action).toBe('removed');
const row = testDb
.prepare('SELECT * FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?')
.get(user.id, plan.id, '2025-08-02');
expect(row).toBeUndefined();
});
});
// ── toggleCompanyHoliday ──────────────────────────────────────────────────────
describe('toggleCompanyHoliday', () => {
it('VACAY-SVC-034: adds a company holiday on first call (action: added)', () => {
const { plan } = setupUserWithPlan();
const result = toggleCompanyHoliday(plan.id, '2025-12-25', 'Christmas', undefined);
expect(result.action).toBe('added');
const row = testDb
.prepare('SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date = ?')
.get(plan.id, '2025-12-25');
expect(row).toBeDefined();
});
it('VACAY-SVC-035: removes the company holiday on second call (action: removed)', () => {
const { plan } = setupUserWithPlan();
toggleCompanyHoliday(plan.id, '2025-12-26', 'Boxing Day', undefined);
const result = toggleCompanyHoliday(plan.id, '2025-12-26', undefined, undefined);
expect(result.action).toBe('removed');
const row = testDb
.prepare('SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date = ?')
.get(plan.id, '2025-12-26');
expect(row).toBeUndefined();
});
it('VACAY-SVC-036: adding a company holiday removes any existing vacay_entry on that date', () => {
const { user, plan } = setupUserWithPlan();
// First add a personal entry on that date
toggleEntry(user.id, plan.id, '2025-05-01', undefined);
// Now declare it a company holiday — the personal entry should be wiped
toggleCompanyHoliday(plan.id, '2025-05-01', 'Labour Day', undefined);
const personalEntry = testDb
.prepare('SELECT * FROM vacay_entries WHERE plan_id = ? AND date = ?')
.get(plan.id, '2025-05-01');
expect(personalEntry).toBeUndefined();
});
});
// ── acceptInvite / declineInvite / cancelInvite ───────────────────────────────
describe('acceptInvite', () => {
it('VACAY-SVC-037: changes membership status to accepted', () => {
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
const { user: invitee } = createUser(testDb);
getOwnPlan(invitee.id); // ensure own plan exists for data migration path
insertMember(ownerPlan.id, invitee.id, 'pending');
const result = acceptInvite(invitee.id, ownerPlan.id, undefined);
expect(result.error).toBeUndefined();
const row = testDb
.prepare('SELECT status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?')
.get(ownerPlan.id, invitee.id) as { status: string } | undefined;
expect(row?.status).toBe('accepted');
});
it('VACAY-SVC-038: returns 404 error when there is no pending invite', () => {
const { user } = createUser(testDb);
const result = acceptInvite(user.id, 99999, undefined);
expect(result.status).toBe(404);
expect(result.error).toBeDefined();
});
it('VACAY-SVC-039: accepted member becomes visible via getActivePlan', () => {
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
const { user: invitee } = createUser(testDb);
getOwnPlan(invitee.id);
insertMember(ownerPlan.id, invitee.id, 'pending');
acceptInvite(invitee.id, ownerPlan.id, undefined);
const active = getActivePlan(invitee.id);
expect(active.id).toBe(ownerPlan.id);
});
});
describe('declineInvite', () => {
it('VACAY-SVC-040: removes the pending invite row', () => {
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
const { user: invitee } = createUser(testDb);
insertMember(ownerPlan.id, invitee.id, 'pending');
declineInvite(invitee.id, ownerPlan.id, undefined);
const row = testDb
.prepare('SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?')
.get(ownerPlan.id, invitee.id);
expect(row).toBeUndefined();
});
});
describe('cancelInvite', () => {
it('VACAY-SVC-041: removes the pending invite when owner cancels it', () => {
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
const { user: target } = createUser(testDb);
insertMember(ownerPlan.id, target.id, 'pending');
cancelInvite(ownerPlan.id, target.id);
const row = testDb
.prepare('SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?')
.get(ownerPlan.id, target.id);
expect(row).toBeUndefined();
});
});
// ── getAvailableUsers ─────────────────────────────────────────────────────────
describe('getAvailableUsers', () => {
it('VACAY-SVC-042: returns users not already in the plan and not fused elsewhere', () => {
const { user: owner, plan } = setupUserWithPlan();
const { user: unrelated } = createUser(testDb);
getOwnPlan(unrelated.id);
const available = getAvailableUsers(owner.id, plan.id) as { id: number }[];
expect(available.map(u => u.id)).toContain(unrelated.id);
// Owner themselves should NOT appear (excluded by u.id != ?)
expect(available.map(u => u.id)).not.toContain(owner.id);
});
it('VACAY-SVC-043: excludes users who already have an accepted membership in any plan', () => {
const { user: owner, plan } = setupUserWithPlan();
const { user: alreadyFused } = createUser(testDb);
const { plan: otherPlan } = setupUserWithPlan();
insertMember(otherPlan.id, alreadyFused.id, 'accepted');
const available = getAvailableUsers(owner.id, plan.id) as { id: number }[];
expect(available.map(u => u.id)).not.toContain(alreadyFused.id);
});
});
// ── getStats ──────────────────────────────────────────────────────────────────
describe('getStats', () => {
it('VACAY-SVC-044: returns per-user stats with correct fields', () => {
const { user, plan } = setupUserWithPlan();
const yr = new Date().getFullYear();
const stats = getStats(plan.id, yr);
expect(stats).toHaveLength(1);
expect(stats[0]).toMatchObject({
user_id: user.id,
year: yr,
vacation_days: 30,
used: 0,
remaining: 30,
});
});
it('VACAY-SVC-045: used reflects the actual number of entries for that user and year', () => {
const { user, plan } = setupUserWithPlan();
const yr = new Date().getFullYear();
toggleEntry(user.id, plan.id, `${yr}-09-10`, undefined);
toggleEntry(user.id, plan.id, `${yr}-09-11`, undefined);
const stats = getStats(plan.id, yr);
expect(stats[0].used).toBe(2);
expect(stats[0].remaining).toBe(28);
});
});
// ── applyHolidayCalendars ─────────────────────────────────────────────────────
describe('applyHolidayCalendars', () => {
it('VACAY-SVC-046: does nothing when holidays_enabled is 0 (fetch is never called)', async () => {
const { plan } = setupUserWithPlan();
// holidays_enabled defaults to 0
await applyHolidayCalendars(plan.id);
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
});
it('VACAY-SVC-047: deletes matching vacay_entries for a global holiday date returned by the API', async () => {
const { user, plan } = setupUserWithPlan();
const yr = new Date().getFullYear();
// Enable holidays and add a calendar
testDb.prepare('UPDATE vacay_plans SET holidays_enabled = 1 WHERE id = ?').run(plan.id);
addHolidayCalendar(plan.id, 'DE', null, undefined, 0, undefined);
// Add a vacay entry on the holiday date
const holidayDate = `${yr}-01-01`;
testDb
.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)')
.run(plan.id, user.id, holidayDate, '');
// Override fetch to return one global holiday matching that entry
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => [{ date: holidayDate, global: true }],
}));
await applyHolidayCalendars(plan.id);
const remaining = testDb
.prepare('SELECT * FROM vacay_entries WHERE plan_id = ? AND date = ?')
.all(plan.id, holidayDate);
expect(remaining).toHaveLength(0);
});
});
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
// Prevent the module-level setInterval from running during tests
vi.useFakeTimers();
@@ -8,7 +8,14 @@ vi.stubGlobal('fetch', vi.fn());
afterAll(() => vi.unstubAllGlobals());
import { estimateCondition, cacheKey } from '../../../src/services/weatherService';
import {
estimateCondition,
cacheKey,
getWeather,
getDetailedWeather,
ApiError,
type WeatherResult,
} from '../../../src/services/weatherService';
// ── estimateCondition ────────────────────────────────────────────────────────
@@ -105,3 +112,585 @@ describe('cacheKey', () => {
expect(cacheKey('0', '0', 'climate')).toBe('0.00_0.00_climate');
});
});
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Build a minimal mock Response for fetch. */
function mockResponse(body: unknown, ok = true, status = 200): Response {
return {
ok,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
/** ISO date string offset by `days` from now (fake-timer "now"). */
function dateOffset(days: number): string {
const d = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
return d.toISOString().slice(0, 10);
}
// ── getWeather ────────────────────────────────────────────────────────────────
describe('getWeather', () => {
// Use coordinates that are unique per describe block to avoid cross-test cache
// pollution. Each nested describe uses a distinct lat so the module-level Map
// never returns stale data from a sibling test.
beforeEach(() => {
vi.mocked(fetch).mockReset();
});
describe('with date — cache hit', () => {
it('returns cached result without calling fetch', async () => {
const date = dateOffset(2);
const forecastBody = {
daily: {
time: [date],
temperature_2m_max: [20],
temperature_2m_min: [10],
weathercode: [0],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(forecastBody));
// First call populates the cache
const first = await getWeather('10.00', '20.00', date, 'en');
expect(fetch).toHaveBeenCalledTimes(1);
vi.mocked(fetch).mockReset();
// Second call with identical arguments should be served from cache
const second = await getWeather('10.00', '20.00', date, 'en');
expect(fetch).not.toHaveBeenCalled();
expect(second).toEqual(first);
});
});
describe('with date — forecast path (diffDays -1 .. +16)', () => {
it('returns a forecast WeatherResult for a date 3 days away', async () => {
const date = dateOffset(3);
const body = {
daily: {
time: [date],
temperature_2m_max: [25],
temperature_2m_min: [15],
weathercode: [1],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('11.00', '21.00', date, 'en');
expect(result.type).toBe('forecast');
expect(result.temp).toBe(20); // (25+15)/2
expect(result.temp_max).toBe(25);
expect(result.temp_min).toBe(15);
expect(result.main).toBe('Clear'); // WMO code 1
expect(result.description).toBe('Mainly clear');
});
it('uses German descriptions when lang is "de"', async () => {
const date = dateOffset(4);
const body = {
daily: {
time: [date],
temperature_2m_max: [10],
temperature_2m_min: [5],
weathercode: [3],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('11.01', '21.01', date, 'de');
expect(result.description).toBe('Bewolkt'); // German for code 3
});
it('falls back to "Clouds" for an unknown WMO code', async () => {
const date = dateOffset(5);
const body = {
daily: {
time: [date],
temperature_2m_max: [10],
temperature_2m_min: [5],
weathercode: [999], // not in WMO_MAP
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('11.02', '21.02', date, 'en');
expect(result.main).toBe('Clouds');
});
it('throws ApiError when response.ok is false', async () => {
const date = dateOffset(2);
const body = { reason: 'rate limited' };
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body, false, 429));
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body, false, 429));
await expect(getWeather('12.00', '22.00', date, 'en')).rejects.toThrow(ApiError);
await expect(getWeather('12.00', '22.00', date, 'en')).rejects.toMatchObject({
status: 429,
message: 'rate limited',
});
});
it('throws ApiError when data.error is true', async () => {
const date = dateOffset(2);
const body = { error: true, reason: 'invalid coordinates' };
// Need a fresh coordinate to avoid the cache from the previous test failure
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body, true, 200));
await expect(getWeather('12.01', '22.01', date, 'en')).rejects.toThrow(ApiError);
});
it('falls through to climate path when date is not found in forecast data', async () => {
// The forecast API returns data but NOT for our target date; the code
// checks idx === -1 and falls into the diffDays > -1 climate branch.
const date = dateOffset(3);
const forecastBody = {
daily: {
time: ['1970-01-01'], // deliberately wrong date
temperature_2m_max: [10],
temperature_2m_min: [5],
weathercode: [0],
},
};
// Archive response for the climate fallback
const refDate = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000);
const archiveBody = {
daily: {
time: ['some-date'],
temperature_2m_max: [18],
temperature_2m_min: [8],
precipitation_sum: [0],
},
};
vi.mocked(fetch)
.mockResolvedValueOnce(mockResponse(forecastBody))
.mockResolvedValueOnce(mockResponse(archiveBody));
const result = await getWeather('13.00', '23.00', date, 'en');
expect(result.type).toBe('climate');
expect(fetch).toHaveBeenCalledTimes(2);
});
});
describe('with date — past date (diffDays < -1)', () => {
it('returns no_forecast error immediately without fetching', async () => {
const date = dateOffset(-5); // 5 days in the past
const result = await getWeather('14.00', '24.00', date, 'en');
expect(result.error).toBe('no_forecast');
expect(fetch).not.toHaveBeenCalled();
});
});
describe('with date — climate / archive path (diffDays > 16)', () => {
it('returns a climate WeatherResult for a far-future date', async () => {
const date = dateOffset(20);
const body = {
daily: {
time: ['2025-01-01', '2025-01-02'],
temperature_2m_max: [22, 24],
temperature_2m_min: [12, 14],
precipitation_sum: [0, 0.1],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('15.00', '25.00', date, 'en');
expect(result.type).toBe('climate');
expect(result.temp).toBe(18); // avg of (22+12)/2=17 and (24+14)/2=19 -> avg 18
expect(result.temp_max).toBe(23);
expect(result.temp_min).toBe(13);
});
it('throws ApiError when archive API response.ok is false', async () => {
const date = dateOffset(20);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'server error' }, false, 500));
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'server error' }, false, 500));
await expect(getWeather('15.01', '25.01', date, 'en')).rejects.toThrow(ApiError);
await expect(getWeather('15.01', '25.01', date, 'en')).rejects.toMatchObject({ status: 500 });
});
it('returns no_forecast when archive daily data is missing', async () => {
const date = dateOffset(20);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({}));
const result = await getWeather('15.02', '25.02', date, 'en');
expect(result.error).toBe('no_forecast');
});
it('returns no_forecast when archive daily.time is empty', async () => {
const date = dateOffset(20);
const body = { daily: { time: [], temperature_2m_max: [], temperature_2m_min: [], precipitation_sum: [] } };
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('15.03', '25.03', date, 'en');
expect(result.error).toBe('no_forecast');
});
it('returns no_forecast when all temperature entries are null', async () => {
const date = dateOffset(20);
const body = {
daily: {
time: ['2025-01-01'],
temperature_2m_max: [null],
temperature_2m_min: [null],
precipitation_sum: [0],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('15.04', '25.04', date, 'en');
expect(result.error).toBe('no_forecast');
});
});
describe('without date — current weather path', () => {
it('returns current WeatherResult', async () => {
const body = {
current: { temperature_2m: 18.7, weathercode: 2 },
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('16.00', '26.00', undefined, 'en');
expect(result.type).toBe('current');
expect(result.temp).toBe(19); // Math.round(18.7)
expect(result.main).toBe('Clouds'); // WMO code 2
expect(result.description).toBe('Partly cloudy');
});
it('uses German descriptions when lang is "de"', async () => {
const body = { current: { temperature_2m: 10, weathercode: 45 } };
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('16.01', '26.01', undefined, 'de');
expect(result.description).toBe('Nebel');
});
it('returns cached current weather on second identical call', async () => {
const body = { current: { temperature_2m: 22, weathercode: 0 } };
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const first = await getWeather('16.02', '26.02', undefined, 'en');
vi.mocked(fetch).mockReset();
const second = await getWeather('16.02', '26.02', undefined, 'en');
expect(fetch).not.toHaveBeenCalled();
expect(second).toEqual(first);
});
it('throws ApiError when current weather API returns error', async () => {
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'bad request' }, false, 400));
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'bad request' }, false, 400));
await expect(getWeather('16.03', '26.03', undefined, 'en')).rejects.toThrow(ApiError);
await expect(getWeather('16.03', '26.03', undefined, 'en')).rejects.toMatchObject({ status: 400 });
});
it('throws ApiError when data.error flag is set on current weather response', async () => {
const body = { error: true, reason: 'quota exceeded' };
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body, true, 200));
await expect(getWeather('16.04', '26.04', undefined, 'en')).rejects.toThrow(ApiError);
});
});
});
// ── getDetailedWeather ────────────────────────────────────────────────────────
describe('getDetailedWeather', () => {
beforeEach(() => {
vi.mocked(fetch).mockReset();
});
describe('cache hit', () => {
it('returns cached result without calling fetch a second time', async () => {
const date = dateOffset(5);
const dailyBody = {
daily: {
time: [date],
temperature_2m_max: [28],
temperature_2m_min: [18],
weathercode: [0],
precipitation_sum: [0],
precipitation_probability_max: [0],
windspeed_10m_max: [10],
sunrise: [`${date}T06:00`],
sunset: [`${date}T20:00`],
},
hourly: { time: [], temperature_2m: [] },
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(dailyBody));
const first = await getDetailedWeather('30.00', '40.00', date, 'en');
vi.mocked(fetch).mockReset();
const second = await getDetailedWeather('30.00', '40.00', date, 'en');
expect(fetch).not.toHaveBeenCalled();
expect(second).toEqual(first);
});
});
describe('forecast path (diffDays <= 16)', () => {
it('returns a detailed forecast WeatherResult with hourly data', async () => {
const date = dateOffset(6);
const body = {
daily: {
time: [date],
temperature_2m_max: [30],
temperature_2m_min: [20],
weathercode: [80],
precipitation_sum: [5],
precipitation_probability_max: [70],
windspeed_10m_max: [15],
sunrise: [`${date}T05:45`],
sunset: [`${date}T21:15`],
},
hourly: {
time: [`${date}T12:00`, `${date}T13:00`],
temperature_2m: [28, 29],
precipitation_probability: [60, 65],
precipitation: [1.2, 0.8],
weathercode: [80, 81],
windspeed_10m: [12, 14],
relativehumidity_2m: [70, 68],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getDetailedWeather('31.00', '41.00', date, 'en');
expect(result.type).toBe('forecast');
expect(result.temp).toBe(25); // (30+20)/2
expect(result.temp_max).toBe(30);
expect(result.temp_min).toBe(20);
expect(result.main).toBe('Rain'); // WMO code 80
expect(result.precipitation_sum).toBe(5);
expect(result.precipitation_probability_max).toBe(70);
expect(result.wind_max).toBe(15);
expect(result.sunrise).toBe('05:45');
expect(result.sunset).toBe('21:15');
expect(result.hourly).toHaveLength(2);
expect(result.hourly![0].temp).toBe(28);
expect(result.hourly![0].precipitation_probability).toBe(60);
expect(result.hourly![1].main).toBe('Rain'); // WMO code 81
});
it('returns no_forecast when daily data is missing', async () => {
const date = dateOffset(7);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({}));
const result = await getDetailedWeather('31.01', '41.01', date, 'en');
expect(result.error).toBe('no_forecast');
});
it('returns no_forecast when daily.time is empty', async () => {
const date = dateOffset(7);
const body = {
daily: {
time: [],
temperature_2m_max: [],
temperature_2m_min: [],
weathercode: [],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getDetailedWeather('31.02', '41.02', date, 'en');
expect(result.error).toBe('no_forecast');
});
it('throws ApiError when forecast API returns !ok', async () => {
const date = dateOffset(8);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'not found' }, false, 404));
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'not found' }, false, 404));
await expect(getDetailedWeather('31.03', '41.03', date, 'en')).rejects.toThrow(ApiError);
await expect(getDetailedWeather('31.03', '41.03', date, 'en')).rejects.toMatchObject({ status: 404 });
});
it('throws ApiError when data.error flag is set', async () => {
const date = dateOffset(9);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ error: true, reason: 'bad coords' }));
await expect(getDetailedWeather('31.04', '41.04', date, 'en')).rejects.toThrow(ApiError);
});
it('handles missing hourly block gracefully', async () => {
const date = dateOffset(10);
const body = {
daily: {
time: [date],
temperature_2m_max: [20],
temperature_2m_min: [10],
weathercode: [0],
precipitation_sum: [0],
precipitation_probability_max: [0],
windspeed_10m_max: [5],
sunrise: [`${date}T06:00`],
sunset: [`${date}T20:00`],
},
// no hourly field
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getDetailedWeather('31.05', '41.05', date, 'en');
expect(result.type).toBe('forecast');
expect(result.hourly).toEqual([]);
});
});
describe('climate / archive path (diffDays > 16)', () => {
it('returns a detailed climate WeatherResult with hourly data', async () => {
const date = dateOffset(20);
const refDate = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000);
const refYear = refDate.getFullYear() - 1;
const refDateStr = `${refYear}-${String(refDate.getMonth() + 1).padStart(2, '0')}-${String(refDate.getDate()).padStart(2, '0')}`;
const body = {
daily: {
time: [refDateStr],
temperature_2m_max: [26],
temperature_2m_min: [16],
weathercode: [63],
precipitation_sum: [8],
windspeed_10m_max: [20],
sunrise: [`${refDateStr}T06:30`],
sunset: [`${refDateStr}T20:30`],
},
hourly: {
time: [`${refDateStr}T10:00`, `${refDateStr}T11:00`],
temperature_2m: [22, 24],
precipitation: [2, 1],
weathercode: [63, 61],
windspeed_10m: [18, 16],
relativehumidity_2m: [80, 75],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getDetailedWeather('32.00', '42.00', date, 'en');
expect(result.type).toBe('climate');
expect(result.temp).toBe(21); // (26+16)/2
expect(result.temp_max).toBe(26);
expect(result.temp_min).toBe(16);
expect(result.main).toBe('Rain'); // WMO code 63
expect(result.description).toBe('Rain'); // WMO_DESCRIPTION_EN[63]
expect(result.precipitation_sum).toBe(8);
expect(result.wind_max).toBe(20);
expect(result.sunrise).toBe('06:30');
expect(result.sunset).toBe('20:30');
expect(result.hourly).toHaveLength(2);
expect(result.hourly![0].temp).toBe(22);
expect(result.hourly![0].precipitation).toBe(2);
expect(result.hourly![1].main).toBe('Rain'); // WMO code 61
});
it('uses German descriptions when lang is "de"', async () => {
const date = dateOffset(20);
const refDate = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000);
const refYear = refDate.getFullYear() - 1;
const refDateStr = `${refYear}-${String(refDate.getMonth() + 1).padStart(2, '0')}-${String(refDate.getDate()).padStart(2, '0')}`;
const body = {
daily: {
time: [refDateStr],
temperature_2m_max: [20],
temperature_2m_min: [10],
weathercode: [0],
precipitation_sum: [0],
windspeed_10m_max: [5],
},
hourly: { time: [], temperature_2m: [] },
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getDetailedWeather('32.01', '42.01', date, 'de');
expect(result.description).toBe('Klar'); // German WMO_DESCRIPTION_DE[0]
});
it('returns no_forecast when archive daily data is missing', async () => {
const date = dateOffset(20);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({}));
const result = await getDetailedWeather('32.02', '42.02', date, 'en');
expect(result.error).toBe('no_forecast');
});
it('returns no_forecast when archive daily.time is empty', async () => {
const date = dateOffset(20);
const body = { daily: { time: [], temperature_2m_max: [], temperature_2m_min: [], weathercode: [] } };
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getDetailedWeather('32.03', '42.03', date, 'en');
expect(result.error).toBe('no_forecast');
});
it('throws ApiError when archive API returns !ok', async () => {
const date = dateOffset(20);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'upstream error' }, false, 503));
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'upstream error' }, false, 503));
await expect(getDetailedWeather('32.04', '42.04', date, 'en')).rejects.toThrow(ApiError);
await expect(getDetailedWeather('32.04', '42.04', date, 'en')).rejects.toMatchObject({ status: 503 });
});
it('throws ApiError when archive data.error flag is set', async () => {
const date = dateOffset(20);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ error: true, reason: 'quota exceeded' }));
await expect(getDetailedWeather('32.05', '42.05', date, 'en')).rejects.toThrow(ApiError);
});
it('falls back to estimateCondition when archive weathercode is undefined', async () => {
// When daily.weathercode[0] is undefined, the code falls back to
// estimateCondition(avgTemp, precipitation_sum)
const date = dateOffset(20);
const refDate = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000);
const refYear = refDate.getFullYear() - 1;
const refDateStr = `${refYear}-${String(refDate.getMonth() + 1).padStart(2, '0')}-${String(refDate.getDate()).padStart(2, '0')}`;
const body = {
daily: {
time: [refDateStr],
temperature_2m_max: [20],
temperature_2m_min: [10],
// weathercode intentionally omitted — will be undefined
precipitation_sum: [10], // > 5 mm and temp > 0 -> 'Rain'
windspeed_10m_max: [5],
},
hourly: { time: [], temperature_2m: [] },
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getDetailedWeather('32.06', '42.06', date, 'en');
// undefined code -> WMO_MAP[undefined] is undefined -> falls back to estimateCondition
// avgTemp = (20+10)/2 = 15, precip = 10 > 5 and temp 15 > 0 -> 'Rain'
expect(result.main).toBe('Rain');
});
});
});
+105 -1
View File
@@ -1,13 +1,27 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Capture Agent constructor options so we can test the lookup callback
const { agentCapture } = vi.hoisted(() => ({ agentCapture: { options: null as any } }));
// Mock dns/promises to avoid real DNS lookups in unit tests
vi.mock('dns/promises', () => ({
default: { lookup: vi.fn() },
lookup: vi.fn(),
}));
// Mock undici Agent so we can inspect the connect.lookup option
vi.mock('undici', () => ({
Agent: class MockAgent {
options: any;
constructor(opts: any) {
this.options = opts;
agentCapture.options = opts;
}
},
}));
import dns from 'dns/promises';
import { checkSsrf } from '../../../src/utils/ssrfGuard';
import { checkSsrf, SsrfBlockedError, safeFetch, createPinnedDispatcher } from '../../../src/utils/ssrfGuard';
const mockLookup = vi.mocked(dns.lookup);
@@ -142,4 +156,94 @@ describe('checkSsrf', () => {
expect(result.allowed).toBe(false);
});
});
describe('DNS resolution failure', () => {
it('returns allowed:false when dns.lookup throws', async () => {
mockLookup.mockRejectedValue(new Error('ENOTFOUND nxdomain.example'));
const result = await checkSsrf('http://nxdomain.example.com');
expect(result.allowed).toBe(false);
expect(result.isPrivate).toBe(false);
expect(result.error).toBe('Could not resolve hostname');
});
});
});
describe('SsrfBlockedError', () => {
it('is an instance of Error', () => {
const err = new SsrfBlockedError('blocked');
expect(err).toBeInstanceOf(Error);
});
it('has name SsrfBlockedError', () => {
const err = new SsrfBlockedError('test message');
expect(err.name).toBe('SsrfBlockedError');
});
it('has the correct message', () => {
const err = new SsrfBlockedError('my message');
expect(err.message).toBe('my message');
});
});
describe('safeFetch', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('throws SsrfBlockedError for a blocked URL (invalid URL)', async () => {
await expect(safeFetch('not-a-valid-url')).rejects.toThrow(SsrfBlockedError);
});
it('throws SsrfBlockedError for a loopback URL', async () => {
mockLookup.mockResolvedValue({ address: '127.0.0.1', family: 4 });
await expect(safeFetch('http://localhost')).rejects.toThrow(SsrfBlockedError);
});
it('calls fetch with the resolved URL when allowed', async () => {
mockLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 });
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal('fetch', mockFetch);
const result = await safeFetch('https://example.com');
expect(mockFetch).toHaveBeenCalledOnce();
expect(result.status).toBe(200);
});
it('throws SsrfBlockedError with fallback message when error is undefined', async () => {
// non-http protocol → error:'Only HTTP and HTTPS URLs are allowed'
await expect(safeFetch('ftp://example.com')).rejects.toThrow(SsrfBlockedError);
});
});
describe('createPinnedDispatcher', () => {
it('returns an object (Agent instance)', () => {
const dispatcher = createPinnedDispatcher('93.184.216.34');
expect(dispatcher).toBeDefined();
expect(typeof dispatcher).toBe('object');
});
it('pinned lookup callback calls back with the resolved IPv4 address', () => {
createPinnedDispatcher('93.184.216.34');
const lookup = agentCapture.options?.connect?.lookup;
expect(typeof lookup).toBe('function');
const cb = vi.fn();
lookup('example.com', {}, cb);
expect(cb).toHaveBeenCalledWith(null, '93.184.216.34', 4);
});
it('pinned lookup callback uses family 6 for IPv6 address', () => {
createPinnedDispatcher('2001:4860:4860::8888');
const lookup = agentCapture.options?.connect?.lookup;
const cb = vi.fn();
lookup('example.com', {}, cb);
expect(cb).toHaveBeenCalledWith(null, '2001:4860:4860::8888', 6);
});
it('returns array format when opts.all is true', () => {
createPinnedDispatcher('93.184.216.34');
const lookup = agentCapture.options?.connect?.lookup;
const cb = vi.fn();
lookup('example.com', { all: true }, cb);
expect(cb).toHaveBeenCalledWith(null, [{ address: '93.184.216.34', family: 4 }]);
});
});
+549 -2
View File
@@ -1,12 +1,14 @@
/**
* WebSocket connection tests.
* Covers WS-001 to WS-006, WS-008 to WS-010.
* Covers WS-001 to WS-006, WS-008 to WS-017.
*
* Starts a real HTTP server on a random port and connects via the `ws` library.
*/
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import http from 'http';
import request from 'supertest';
import WebSocket from 'ws';
import { broadcastToUser, getOnlineUserIds } from '../../src/websocket';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -44,6 +46,7 @@ import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser, createTrip } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { setupWebSocket } from '../../src/websocket';
import { createEphemeralToken } from '../../src/services/ephemeralTokens';
@@ -280,3 +283,547 @@ describe('WS rate limiting', () => {
}
});
});
describe('WS real-time broadcast', () => {
it('WS-009 — POST /api/trips/:id/places broadcasts place:created to room members', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const token = createEphemeralToken(user.id, 'ws')!;
const client = await connectWs(token);
try {
await client.next(); // welcome
// Join the trip room
client.send({ type: 'join', tripId: trip.id });
await client.next(); // joined
// Create a place via REST (from a different socket, so it broadcasts to us)
const wsToken2 = createEphemeralToken(user.id, 'ws')!;
const client2 = await connectWs(wsToken2);
try {
await client2.next(); // welcome
client2.send({ type: 'join', tripId: trip.id });
await client2.next(); // joined
// REST call from client2's socket ID
const welcome2SocketId = (await Promise.resolve(null)) ?? null;
await request(server)
.post(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Test Place', lat: 48.8566, lng: 2.3522 });
// client should receive the broadcast
const msg = await client.waitFor((m: any) => m.type === 'place:created', 3000);
expect(msg.type).toBe('place:created');
expect(msg.place).toBeDefined();
expect(msg.place.name).toBe('Test Place');
} finally {
client2.close();
}
} finally {
client.close();
}
});
it('WS-010 — ephemeral WS token is single-use (second connection is rejected)', async () => {
const { user } = createUser(testDb);
const token = createEphemeralToken(user.id, 'ws')!;
// First connection: should succeed
const client = await connectWs(token);
await client.next(); // welcome
client.close();
// Second connection with same token: should be rejected with code 4001
const closeCode = await new Promise<number>((resolve, reject) => {
const ws = new WebSocket(`${wsUrl}?token=${encodeURIComponent(token)}`);
ws.once('close', (code) => resolve(code));
ws.once('error', () => resolve(4001)); // connection error also means rejection
setTimeout(() => reject(new Error('Timeout waiting for rejection')), 3000);
});
expect([4001, 1006]).toContain(closeCode); // 4001 = auth rejected, 1006 = abnormal close (also rejection)
});
it('WS-011 — client not in trip room does not receive broadcast', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
// Connect `other` user but do NOT join the trip room
const tokenOther = createEphemeralToken(other.id, 'ws')!;
const clientOther = await connectWs(tokenOther);
try {
await clientOther.next(); // welcome — but no join
// Owner creates a place
await request(server)
.post(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(owner.id))
.send({ name: 'Owner Place', lat: 48.8566, lng: 2.3522 });
// `other` should NOT receive any broadcast within 500ms
const msgs = await clientOther.collectFor(500);
const broadcast = msgs.find((m: any) => m.type === 'place:created');
expect(broadcast).toBeUndefined();
} finally {
clientOther.close();
}
});
});
// ---------------------------------------------------------------------------
// WS auth edge cases — user-not-found and MFA enforcement
// ---------------------------------------------------------------------------
describe('WS auth edge cases', () => {
it('WS-012 — token for non-existent user closes with code 4001', async () => {
// Insert a user, grab an ephemeral token, then delete the user before connecting
const { user } = createUser(testDb);
const token = createEphemeralToken(user.id, 'ws')!;
// Remove the user so the DB lookup returns undefined
testDb.prepare('DELETE FROM users WHERE id = ?').run(user.id);
const closeCode = await new Promise<number>((resolve) => {
const ws = new WebSocket(`${wsUrl}?token=${encodeURIComponent(token)}`);
ws.once('close', (code) => resolve(code));
ws.once('error', () => resolve(4001));
});
expect(closeCode).toBe(4001);
});
it('WS-013 — MFA is enforced when require_mfa is enabled and user has no MFA', async () => {
// Enable require_mfa in app_settings
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
// Create a regular user without MFA
const { user } = createUser(testDb);
const token = createEphemeralToken(user.id, 'ws')!;
const closeCode = await new Promise<number>((resolve) => {
const ws = new WebSocket(`${wsUrl}?token=${encodeURIComponent(token)}`);
ws.once('close', (code) => resolve(code));
ws.once('error', () => resolve(4403));
});
expect(closeCode).toBe(4403);
});
it('WS-014 — MFA-enabled user connects successfully when require_mfa is enabled', async () => {
// Enable require_mfa
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
// Create a user with MFA enabled
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('JBSWY3DPEHPK3PXP', user.id);
const token = createEphemeralToken(user.id, 'ws')!;
const client = await connectWs(token);
try {
const msg = await client.next();
expect(msg.type).toBe('welcome');
} finally {
client.close();
}
});
});
// ---------------------------------------------------------------------------
// WS message processing — malformed/invalid payloads
// ---------------------------------------------------------------------------
/** Connect a raw WebSocket (no WsClient wrapper) using a raw-send capable helper. */
function connectRawWs(token: string): Promise<{ ws: WebSocket; received: any[] }> {
return new Promise((resolve, reject) => {
const received: any[] = [];
const ws = new WebSocket(`${wsUrl}?token=${encodeURIComponent(token)}`);
ws.on('message', (data) => {
try { received.push(JSON.parse(data.toString())); } catch { /* ignore parse errors */ }
});
ws.once('open', () => resolve({ ws, received }));
ws.once('error', reject);
ws.once('close', (code) => { if (code === 4001) reject(new Error('WS closed 4001')); });
});
}
/** Wait until `received` array has at least `n` items, up to `timeoutMs`. */
function waitForMessages(received: any[], n = 1, timeoutMs = 3000): Promise<void> {
return new Promise((resolve, reject) => {
if (received.length >= n) { resolve(); return; }
const start = Date.now();
const poll = () => {
if (received.length >= n) { resolve(); return; }
if (Date.now() - start > timeoutMs) { reject(new Error(`Timeout waiting for ${n} messages`)); return; }
setTimeout(poll, 20);
};
poll();
});
}
describe('WS message processing edge cases', () => {
it('WS-015 — malformed JSON is silently ignored (no crash, no error response)', async () => {
const { user } = createUser(testDb);
const token = createEphemeralToken(user.id, 'ws')!;
const { ws: rawWs, received } = await connectRawWs(token);
// Wait for welcome
await waitForMessages(received, 1);
// Send raw malformed JSON — server should silently ignore and not close connection
rawWs.send('{ this is not json }');
rawWs.send('{broken');
await new Promise(r => setTimeout(r, 300));
// No error messages should have been sent by the server
const errMsgs = received.filter(m => m.type === 'error');
expect(errMsgs).toHaveLength(0);
// Connection should still be open
expect(rawWs.readyState).toBe(WebSocket.OPEN);
rawWs.close();
});
it('WS-015b — message with non-object payload is silently ignored', async () => {
const { user } = createUser(testDb);
const token = createEphemeralToken(user.id, 'ws')!;
const { ws: rawWs, received } = await connectRawWs(token);
// Wait for welcome
await waitForMessages(received, 1);
// Send valid JSON but not an object (array) — should be ignored
rawWs.send(JSON.stringify([1, 2, 3]));
// Send valid JSON number — should be ignored
rawWs.send('42');
await new Promise(r => setTimeout(r, 300));
// The only message received should be the welcome; no errors emitted
const errors = received.filter(m => m.type === 'error');
expect(errors).toHaveLength(0);
rawWs.close();
});
it('WS-015c — message object missing type field is silently ignored', async () => {
const { user } = createUser(testDb);
const token = createEphemeralToken(user.id, 'ws')!;
const { ws: rawWs, received } = await connectRawWs(token);
// Wait for welcome
await waitForMessages(received, 1);
// Object without a string `type` field
rawWs.send(JSON.stringify({ tripId: 1 }));
rawWs.send(JSON.stringify({ type: 42, tripId: 1 }));
await new Promise(r => setTimeout(r, 300));
const errors = received.filter(m => m.type === 'error');
expect(errors).toHaveLength(0);
rawWs.close();
});
it('WS-016 — rate-limit window resets: after limit hit, next window accepts messages again', async () => {
// Exercises line 108-110: the `now - rate.windowStart > WS_MSG_WINDOW` branch (counter reset).
// We confirm that:
// (a) msg 31 triggers the rate-limit error (current window),
// (b) a trip join in the same window is blocked,
// (c) after the rate-limit trip-join is blocked we verify the counter path was reached.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const token = createEphemeralToken(user.id, 'ws')!;
const { ws: rawWs, received } = await connectRawWs(token);
// Wait for welcome
await waitForMessages(received, 1);
// Send exactly 30 messages (the limit) — all should succeed (no rate-limit error yet)
for (let i = 0; i < 30; i++) {
rawWs.send(JSON.stringify({ type: 'noop' }));
}
await new Promise(r => setTimeout(r, 200));
// Message 31 — triggers the `count > WS_MSG_LIMIT` branch, sends rate-limit error
rawWs.send(JSON.stringify({ type: 'noop' }));
await waitForMessages(received, 2, 3000); // welcome + rate-limit error
const rateLimitErrors = received.filter(m => m.type === 'error' && m.message?.includes('Rate limit'));
expect(rateLimitErrors.length).toBeGreaterThanOrEqual(1);
rawWs.close();
});
});
// ---------------------------------------------------------------------------
// WS room management — disconnect cleanup and leave-nonexistent-room
// ---------------------------------------------------------------------------
describe('WS disconnect and room cleanup', () => {
it('WS-017 — disconnecting cleans up room membership so broadcast stops reaching the client', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const token1 = createEphemeralToken(user.id, 'ws')!;
// Connect and join the room
const client = await connectWs(token1);
await client.next(); // welcome
client.send({ type: 'join', tripId: trip.id });
await client.next(); // joined
// Disconnect — triggers the 'close' handler that calls leaveRoom for all rooms
client.close();
await new Promise(r => setTimeout(r, 200)); // let the close event propagate
// Now create a second client that also joins the room, then creates a place.
// The first client (now disconnected) must NOT receive it (it can't, but more
// importantly the server must not crash when iterating rooms and finding a gone socket).
const token2 = createEphemeralToken(user.id, 'ws')!;
const client2 = await connectWs(token2);
try {
await client2.next(); // welcome
client2.send({ type: 'join', tripId: trip.id });
await client2.next(); // joined
// REST call to create a place — triggers broadcast; if room cleanup failed,
// iterating a closed socket would surface here.
const res = await request(server)
.post(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Post-Disconnect Place', lat: 48.8566, lng: 2.3522 });
expect(res.status).toBe(201);
// client2 should still receive the broadcast
const msg = await client2.waitFor((m: any) => m.type === 'place:created', 3000);
expect(msg.place.name).toBe('Post-Disconnect Place');
} finally {
client2.close();
}
});
it('WS-018 — leaving a room the client was never in is a no-op (no crash, no error)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const token = createEphemeralToken(user.id, 'ws')!;
const client = await connectWs(token);
try {
await client.next(); // welcome
// Send leave without ever joining — the server should respond with 'left'
// and not throw, since leaveRoom is defensive about missing rooms/sockets.
client.send({ type: 'leave', tripId: trip.id });
const msg = await client.next();
expect(msg.type).toBe('left');
expect(msg.tripId).toBe(trip.id);
} finally {
client.close();
}
});
});
// ---------------------------------------------------------------------------
// broadcastToUser() and getOnlineUserIds() — exported utility coverage
// ---------------------------------------------------------------------------
describe('broadcastToUser and getOnlineUserIds', () => {
it('WS-019 — broadcastToUser sends payload to all connected sockets for that user', async () => {
const { user } = createUser(testDb);
const token = createEphemeralToken(user.id, 'ws')!;
const client = await connectWs(token);
try {
await client.next(); // welcome
// Call broadcastToUser directly
broadcastToUser(user.id, { type: 'test:direct', data: 'hello' });
const msg = await client.next();
expect(msg.type).toBe('test:direct');
expect(msg.data).toBe('hello');
} finally {
client.close();
}
});
it('WS-020 — broadcastToUser with excludeSid does not send to the excluded socket', async () => {
const { user } = createUser(testDb);
// Connect two sockets for the same user
const token1 = createEphemeralToken(user.id, 'ws')!;
const token2 = createEphemeralToken(user.id, 'ws')!;
const client1 = await connectWs(token1);
const client2 = await connectWs(token2);
try {
const welcome1 = await client1.next();
const welcome2 = await client2.next();
const sid1 = welcome1.socketId;
// Broadcast excluding client1's socket ID
broadcastToUser(user.id, { type: 'test:exclude' }, sid1);
// client2 should receive it
const msg2 = await client2.next();
expect(msg2.type).toBe('test:exclude');
// client1 should NOT receive it within 400ms
const msgs1 = await client1.collectFor(400);
const received = msgs1.find((m: any) => m.type === 'test:exclude');
expect(received).toBeUndefined();
} finally {
client1.close();
client2.close();
}
});
it('WS-021 — broadcastToUser does not send to sockets belonging to a different user', async () => {
const { user: userA } = createUser(testDb);
const { user: userB } = createUser(testDb);
const tokenA = createEphemeralToken(userA.id, 'ws')!;
const tokenB = createEphemeralToken(userB.id, 'ws')!;
const clientA = await connectWs(tokenA);
const clientB = await connectWs(tokenB);
try {
await clientA.next(); // welcome
await clientB.next(); // welcome
// Broadcast only to userA
broadcastToUser(userA.id, { type: 'test:userA-only' });
// userA's client receives it
const msgA = await clientA.next();
expect(msgA.type).toBe('test:userA-only');
// userB's client must NOT receive it within 400ms
const msgsB = await clientB.collectFor(400);
const leak = msgsB.find((m: any) => m.type === 'test:userA-only');
expect(leak).toBeUndefined();
} finally {
clientA.close();
clientB.close();
}
});
it('WS-022 — getOnlineUserIds returns IDs of all connected authenticated users', async () => {
const { user: userA } = createUser(testDb);
const { user: userB } = createUser(testDb);
const tokenA = createEphemeralToken(userA.id, 'ws')!;
const tokenB = createEphemeralToken(userB.id, 'ws')!;
const clientA = await connectWs(tokenA);
const clientB = await connectWs(tokenB);
try {
await clientA.next(); // welcome
await clientB.next(); // welcome
const online = getOnlineUserIds();
expect(online.has(userA.id)).toBe(true);
expect(online.has(userB.id)).toBe(true);
} finally {
clientA.close();
clientB.close();
}
});
it('WS-023 — getOnlineUserIds excludes disconnected users', async () => {
const { user } = createUser(testDb);
const token = createEphemeralToken(user.id, 'ws')!;
const client = await connectWs(token);
await client.next(); // welcome
// Verify user is online
expect(getOnlineUserIds().has(user.id)).toBe(true);
// Disconnect
client.close();
await new Promise(r => setTimeout(r, 200));
// User should no longer appear in online set
expect(getOnlineUserIds().has(user.id)).toBe(false);
});
it('WS-024 — broadcastToUser delivers custom payload to the correct connected socket', async () => {
// This directly exercises the broadcastToUser code path end-to-end through the
// exported function, verifying that the correct socket receives the message.
const { user } = createUser(testDb);
const token = createEphemeralToken(user.id, 'ws')!;
const client = await connectWs(token);
try {
await client.next(); // welcome
const customPayload = { type: 'custom:event', value: 99 };
broadcastToUser(user.id, customPayload);
const msg = await client.waitFor((m: any) => m.type === 'custom:event', 2000);
expect(msg.type).toBe('custom:event');
expect(msg.value).toBe(99);
} finally {
client.close();
}
});
it('WS-025 — broadcast() to an empty/nonexistent room is a no-op (no crash)', async () => {
// Exercises line 180: `if (!room || room.size === 0) return`
// A REST mutation on a trip with no connected WS clients triggers broadcast()
// with a room that doesn't exist — must not throw.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// No WebSocket clients join the trip room before the REST call
const res = await request(server)
.post(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(user.id))
.send({ name: 'No Room Place', lat: 10, lng: 20 });
// Server must not crash — 201 confirms broadcast() returned silently
expect(res.status).toBe(201);
});
it('WS-026 — broadcast() skips non-OPEN sockets in the room', async () => {
// This exercises line 185: `if (ws.readyState !== 1) continue`
// We join a room with two clients, forcefully terminate one (so its readyState becomes
// CLOSED while still transiently in the room map), then trigger a broadcast and confirm
// the surviving client receives it without errors.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const token1 = createEphemeralToken(user.id, 'ws')!;
const token2 = createEphemeralToken(user.id, 'ws')!;
const client1 = await connectWs(token1);
const client2 = await connectWs(token2);
try {
await client1.next(); // welcome
await client2.next(); // welcome
client1.send({ type: 'join', tripId: trip.id });
await client1.next(); // joined
client2.send({ type: 'join', tripId: trip.id });
await client2.next(); // joined
// Close client1 abruptly — the underlying socket may momentarily remain in the room map
client1.close();
await new Promise(r => setTimeout(r, 50)); // brief pause
// Trigger broadcast via REST — should not crash even if client1's socket is closed
const res = await request(server)
.post(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Resilience Place', lat: 1, lng: 2 });
expect(res.status).toBe(201);
// client2 should still receive the broadcast
const msg = await client2.waitFor((m: any) => m.type === 'place:created', 3000);
expect(msg.place.name).toBe('Resilience Place');
} finally {
client2.close();
}
});
});