mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
test: expand test suite to 87.3% backend coverage
Add new integration test files covering previously untested routes: - categories.test.ts — GET /api/categories - oidc.test.ts — full OIDC login flow (callback, state, errors) - settings.test.ts — GET/PUT /api/settings, bulk save - tags.test.ts — CRUD for trip tags - todo.test.ts — todo items CRUD and reorder Add new unit test files covering service-layer logic: - adminService.test.ts — user/invite management, packing templates, OIDC settings - atlasService.test.ts — atlas search and place enrichment - authServiceDb.test.ts — DB-backed auth helpers (login, register, MFA) - backupService.test.ts — export/import/restore logic - categoryService.test.ts — category CRUD - dayService.test.ts — day management and accommodation helpers - mapsService.test.ts — route/directions helpers - oidcService.test.ts — OIDC state, auth code, role resolution, user upsert - packingService.test.ts — packing item/bag/template operations - placeService.test.ts — place CRUD and tag attachment - settingsService.test.ts — settings get/set/bulk - tagService.test.ts — tag CRUD - todoService.test.ts — todo CRUD and reorder - tripService.test.ts — trip CRUD, member management, archiving - vacayService.test.ts — vacay integration helpers - tripAccess.test.ts (middleware) — requireTripAccess middleware Expand existing integration and unit test files with additional cases across admin, atlas, auth, backup, collab, days, files, maps, memories (Immich/Synology), notifications, places, reservations, share, vacay, weather, auth middleware, ephemeral tokens, notification preferences, permissions, SSRF guard, and WebSocket connection tests. Update test helpers (factories.ts, test-db.ts) with new factory functions and seed data required by the expanded suite. Fix minor issues in server/src/routes/reservations.ts and server/src/services/atlasService.ts surfaced by new test coverage. Update sonar-project.properties to reflect new coverage thresholds.
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
updateReservation,
|
updateReservation,
|
||||||
deleteReservation,
|
deleteReservation,
|
||||||
} from '../services/reservationService';
|
} from '../services/reservationService';
|
||||||
|
import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService';
|
||||||
|
|
||||||
const router = express.Router({ mergeParams: true });
|
const router = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
@@ -53,7 +54,6 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
|||||||
// Auto-create budget entry if price was provided
|
// Auto-create budget entry if price was provided
|
||||||
if (create_budget_entry && create_budget_entry.total_price > 0) {
|
if (create_budget_entry && create_budget_entry.total_price > 0) {
|
||||||
try {
|
try {
|
||||||
const { createBudgetItem } = require('../services/budgetService');
|
|
||||||
const budgetItem = createBudgetItem(tripId, {
|
const budgetItem = createBudgetItem(tripId, {
|
||||||
name: title,
|
name: title,
|
||||||
category: create_budget_entry.category || type || 'Other',
|
category: create_budget_entry.category || type || 'Other',
|
||||||
@@ -126,7 +126,6 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
|||||||
if (!create_budget_entry || !create_budget_entry.total_price) {
|
if (!create_budget_entry || !create_budget_entry.total_price) {
|
||||||
const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||||
if (linked) {
|
if (linked) {
|
||||||
const { deleteBudgetItem } = require('../services/budgetService');
|
|
||||||
deleteBudgetItem(linked.id, tripId);
|
deleteBudgetItem(linked.id, tripId);
|
||||||
broadcast(tripId, 'budget:deleted', { id: linked.id }, req.headers['x-socket-id'] as string);
|
broadcast(tripId, 'budget:deleted', { id: linked.id }, req.headers['x-socket-id'] as string);
|
||||||
}
|
}
|
||||||
@@ -135,7 +134,6 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
|||||||
// Auto-create or update budget entry if price was provided
|
// Auto-create or update budget entry if price was provided
|
||||||
if (create_budget_entry && create_budget_entry.total_price > 0) {
|
if (create_budget_entry && create_budget_entry.total_price > 0) {
|
||||||
try {
|
try {
|
||||||
const { createBudgetItem, updateBudgetItem } = require('../services/budgetService');
|
|
||||||
const itemName = title || current.title;
|
const itemName = title || current.title;
|
||||||
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ export async function getStats(userId: number) {
|
|||||||
|
|
||||||
const countries = [...countrySet.values()].map(c => {
|
const countries = [...countrySet.values()].map(c => {
|
||||||
const countryTrips = trips.filter(t => c.tripIds.has(t.id));
|
const countryTrips = trips.filter(t => c.tripIds.has(t.id));
|
||||||
const dates = countryTrips.map(t => t.start_date).filter(Boolean).sort();
|
const dates = countryTrips.map(t => t.start_date).filter(Boolean).sort((a, b) => a.localeCompare(b));
|
||||||
return {
|
return {
|
||||||
code: c.code,
|
code: c.code,
|
||||||
placeCount: c.places.length,
|
placeCount: c.places.length,
|
||||||
@@ -272,7 +272,7 @@ export async function getStats(userId: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mostVisited = countries.length > 0 ? countries.reduce((a, b) => a.placeCount > b.placeCount ? a : b) : null;
|
const mostVisited = countries.length > 0 ? countries.reduce((a, b) => a.placeCount > b.placeCount ? a : b, countries[0]) : null;
|
||||||
|
|
||||||
const continents: Record<string, number> = {};
|
const continents: Record<string, number> = {};
|
||||||
countries.forEach(c => {
|
countries.forEach(c => {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { encrypt_api_key } from '../../src/services/apiKeyCrypto';
|
|||||||
|
|
||||||
let _userSeq = 0;
|
let _userSeq = 0;
|
||||||
let _tripSeq = 0;
|
let _tripSeq = 0;
|
||||||
|
let _categorySeq = 0;
|
||||||
|
let _tagSeq = 0;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Users
|
// Users
|
||||||
@@ -579,3 +581,34 @@ export function setSynologyCredentials(
|
|||||||
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?')
|
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?')
|
||||||
.run(url, username, encrypt_api_key(password), userId);
|
.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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,48 +20,73 @@ import Database from 'better-sqlite3';
|
|||||||
import { createTables } from '../../src/db/schema';
|
import { createTables } from '../../src/db/schema';
|
||||||
import { runMigrations } from '../../src/db/migrations';
|
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 = [
|
const RESET_TABLES = [
|
||||||
|
// Collab
|
||||||
'file_links',
|
'file_links',
|
||||||
|
'collab_message_reactions',
|
||||||
'collab_poll_votes',
|
'collab_poll_votes',
|
||||||
'collab_messages',
|
'collab_messages',
|
||||||
'collab_poll_options',
|
|
||||||
'collab_polls',
|
'collab_polls',
|
||||||
'collab_notes',
|
'collab_notes',
|
||||||
|
// Day content
|
||||||
'day_notes',
|
'day_notes',
|
||||||
|
'todo_category_assignees',
|
||||||
|
'todo_items',
|
||||||
'assignment_participants',
|
'assignment_participants',
|
||||||
'day_assignments',
|
'day_assignments',
|
||||||
|
// Places
|
||||||
|
'place_regions',
|
||||||
|
'place_tags',
|
||||||
|
'places',
|
||||||
|
// Packing
|
||||||
'packing_category_assignees',
|
'packing_category_assignees',
|
||||||
|
'packing_bag_members',
|
||||||
'packing_bags',
|
'packing_bags',
|
||||||
|
'packing_template_items',
|
||||||
|
'packing_template_categories',
|
||||||
|
'packing_templates',
|
||||||
'packing_items',
|
'packing_items',
|
||||||
|
// Budget
|
||||||
'budget_item_members',
|
'budget_item_members',
|
||||||
'budget_items',
|
'budget_items',
|
||||||
|
// Photos & files
|
||||||
'trip_photos',
|
'trip_photos',
|
||||||
'trip_album_links',
|
'trip_album_links',
|
||||||
'trip_files',
|
'trip_files',
|
||||||
'share_tokens',
|
|
||||||
'photos',
|
'photos',
|
||||||
|
// Reservations
|
||||||
|
'reservation_day_positions',
|
||||||
'reservations',
|
'reservations',
|
||||||
|
// Accommodations & days
|
||||||
'day_accommodations',
|
'day_accommodations',
|
||||||
'place_tags',
|
|
||||||
'places',
|
|
||||||
'days',
|
'days',
|
||||||
|
// Trip
|
||||||
|
'share_tokens',
|
||||||
'trip_members',
|
'trip_members',
|
||||||
'trips',
|
'trips',
|
||||||
|
// Vacay
|
||||||
'vacay_entries',
|
'vacay_entries',
|
||||||
'vacay_company_holidays',
|
'vacay_company_holidays',
|
||||||
'vacay_holiday_calendars',
|
'vacay_holiday_calendars',
|
||||||
'vacay_plan_members',
|
'vacay_plan_members',
|
||||||
|
'vacay_user_colors',
|
||||||
|
'vacay_user_years',
|
||||||
'vacay_years',
|
'vacay_years',
|
||||||
'vacay_plans',
|
'vacay_plans',
|
||||||
'atlas_visited_countries',
|
// Atlas
|
||||||
'atlas_bucket_list',
|
'visited_regions',
|
||||||
|
'visited_countries',
|
||||||
|
'bucket_list',
|
||||||
|
// Notifications & audit
|
||||||
'notification_channel_preferences',
|
'notification_channel_preferences',
|
||||||
'notifications',
|
'notifications',
|
||||||
'audit_log',
|
'audit_log',
|
||||||
'user_settings',
|
// User data
|
||||||
|
'settings',
|
||||||
'mcp_tokens',
|
'mcp_tokens',
|
||||||
'mcp_sessions',
|
|
||||||
'invite_tokens',
|
'invite_tokens',
|
||||||
'tags',
|
'tags',
|
||||||
'app_settings',
|
'app_settings',
|
||||||
@@ -130,8 +155,13 @@ export function createTestDb(): Database.Database {
|
|||||||
*/
|
*/
|
||||||
export function resetTestDb(db: Database.Database): void {
|
export function resetTestDb(db: Database.Database): void {
|
||||||
db.exec('PRAGMA foreign_keys = OFF');
|
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) {
|
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');
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
seedDefaults(db);
|
seedDefaults(db);
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ describe('Admin user management', () => {
|
|||||||
.get('/api/admin/users')
|
.get('/api/admin/users')
|
||||||
.set('Cookie', authCookie(admin.id));
|
.set('Cookie', authCookie(admin.id));
|
||||||
expect(res.status).toBe(200);
|
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 () => {
|
it('ADMIN-002 — POST /admin/users creates a user', async () => {
|
||||||
@@ -142,6 +142,10 @@ describe('Admin user management', () => {
|
|||||||
.set('Cookie', authCookie(admin.id));
|
.set('Cookie', authCookie(admin.id));
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.success).toBe(true);
|
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 () => {
|
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);
|
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 { user: admin } = createAdmin(testDb);
|
||||||
|
|
||||||
const getRes = await request(app)
|
// Change trip_create from its default ('everybody') to 'admin'
|
||||||
.get('/api/admin/permissions')
|
|
||||||
.set('Cookie', authCookie(admin.id));
|
|
||||||
const currentPerms = getRes.body;
|
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.put('/api/admin/permissions')
|
.put('/api/admin/permissions')
|
||||||
.set('Cookie', authCookie(admin.id))
|
.set('Cookie', authCookie(admin.id))
|
||||||
.send({ permissions: currentPerms });
|
.send({ permissions: { trip_create: 'admin' } });
|
||||||
expect(res.status).toBe(200);
|
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 () => {
|
it('ADMIN-008 — PUT /admin/permissions without object returns 400', async () => {
|
||||||
@@ -351,3 +361,171 @@ describe('JWT rotation', () => {
|
|||||||
expect(res.body.success).toBe(true);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -383,3 +383,27 @@ describe('Mark/unmark region', () => {
|
|||||||
expect(deRegions).toBeUndefined();
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Authentication integration tests.
|
* 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.
|
* 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.
|
* 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
|
// Rate limiting (AUTH-004, AUTH-018) — placed last
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -478,3 +539,72 @@ describe('Rate limiting', () => {
|
|||||||
expect(lastStatus).toBe(429);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ vi.mock('../../src/services/backupService', async () => {
|
|||||||
day_of_week: 0,
|
day_of_week: 0,
|
||||||
day_of_month: 1,
|
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 { createAdmin, createUser } from '../helpers/factories';
|
||||||
import { authCookie } from '../helpers/auth';
|
import { authCookie } from '../helpers/auth';
|
||||||
import { loginAttempts, mfaAttempts } from '../../src/routes/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();
|
const app: Application = createApp();
|
||||||
|
|
||||||
@@ -173,3 +183,257 @@ describe('Backup security', () => {
|
|||||||
expect(res.status).toBe(404);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,6 +42,15 @@ vi.mock('../../src/config', () => ({
|
|||||||
updateJwtSecret: () => {},
|
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 { createApp } from '../../src/app';
|
||||||
import { createTables } from '../../src/db/schema';
|
import { createTables } from '../../src/db/schema';
|
||||||
import { runMigrations } from '../../src/db/migrations';
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
@@ -49,6 +58,7 @@ import { resetTestDb } from '../helpers/test-db';
|
|||||||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||||
import { authCookie, generateToken } from '../helpers/auth';
|
import { authCookie, generateToken } from '../helpers/auth';
|
||||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
import * as collabService from '../../src/services/collabService';
|
||||||
|
|
||||||
const app: Application = createApp();
|
const app: Application = createApp();
|
||||||
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||||
@@ -637,4 +647,140 @@ describe('Collab validation', () => {
|
|||||||
.send({ text: 'A'.repeat(5001) });
|
.send({ text: 'A'.repeat(5001) });
|
||||||
expect(res.status).toBe(400);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -434,6 +434,46 @@ describe('Accommodations', () => {
|
|||||||
expect(reservation.confirmation_number).toBe('CONF-XYZ');
|
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 () => {
|
it('ACCOM-003 — Deleting accommodation also removes the linked reservation', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
|
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
testDb.close();
|
testDb.close();
|
||||||
|
fs.rmSync(uploadsDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to upload a file and return the file object
|
// Helper to upload a file and return the file object
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ vi.mock('../../src/config', () => ({
|
|||||||
updateJwtSecret: () => {},
|
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 { createApp } from '../../src/app';
|
||||||
import { createTables } from '../../src/db/schema';
|
import { createTables } from '../../src/db/schema';
|
||||||
import { runMigrations } from '../../src/db/migrations';
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
@@ -47,6 +59,7 @@ import { resetTestDb } from '../helpers/test-db';
|
|||||||
import { createUser } from '../helpers/factories';
|
import { createUser } from '../helpers/factories';
|
||||||
import { authCookie } from '../helpers/auth';
|
import { authCookie } from '../helpers/auth';
|
||||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
import * as mapsService from '../../src/services/mapsService';
|
||||||
|
|
||||||
const app: Application = createApp();
|
const app: Application = createApp();
|
||||||
|
|
||||||
@@ -133,3 +146,135 @@ describe('Maps SSRF protection', () => {
|
|||||||
expect(res.status).toBe(400);
|
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);
|
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);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ vi.mock('../../src/config', () => ({
|
|||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
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 { createApp } from '../../src/app';
|
||||||
import { createTables } from '../../src/db/schema';
|
import { createTables } from '../../src/db/schema';
|
||||||
@@ -316,6 +324,30 @@ describe('Notification test endpoints', () => {
|
|||||||
.send({ url: 'not-a-url' });
|
.send({ url: 'not-a-url' });
|
||||||
expect(res.status).toBe(400);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,6 +42,14 @@ vi.mock('../../src/config', () => ({
|
|||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
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 { createApp } from '../../src/app';
|
||||||
import { createTables } from '../../src/db/schema';
|
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 { createUser, createAdmin, createTrip, createPlace, addTripMember } from '../helpers/factories';
|
||||||
import { authCookie } from '../helpers/auth';
|
import { authCookie } from '../helpers/auth';
|
||||||
import { loginAttempts, mfaAttempts } from '../../src/routes/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 app: Application = createApp();
|
||||||
const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx');
|
const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx');
|
||||||
@@ -63,6 +73,7 @@ beforeEach(() => {
|
|||||||
resetTestDb(testDb);
|
resetTestDb(testDb);
|
||||||
loginAttempts.clear();
|
loginAttempts.clear();
|
||||||
mfaAttempts.clear();
|
mfaAttempts.clear();
|
||||||
|
invalidatePermissionsCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
@@ -528,3 +539,179 @@ describe('GPX Import', () => {
|
|||||||
expect(res.status).toBe(400);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { createApp } from '../../src/app';
|
|||||||
import { createTables } from '../../src/db/schema';
|
import { createTables } from '../../src/db/schema';
|
||||||
import { runMigrations } from '../../src/db/migrations';
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
import { resetTestDb } from '../helpers/test-db';
|
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 { authCookie } from '../helpers/auth';
|
||||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
@@ -187,6 +187,43 @@ describe('Update reservation', () => {
|
|||||||
.send({ title: 'Updated' });
|
.send({ title: 'Updated' });
|
||||||
expect(res.status).toBe(404);
|
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);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,7 +41,7 @@ import { createApp } from '../../src/app';
|
|||||||
import { createTables } from '../../src/db/schema';
|
import { createTables } from '../../src/db/schema';
|
||||||
import { runMigrations } from '../../src/db/migrations';
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
import { resetTestDb } from '../helpers/test-db';
|
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 { authCookie } from '../helpers/auth';
|
||||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
@@ -205,3 +205,83 @@ describe('Shared trip access', () => {
|
|||||||
expect(res.status).toBe(404);
|
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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -303,3 +303,230 @@ describe('Vacay dissolve plan', () => {
|
|||||||
expect(res.status).toBe(200);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -153,4 +153,110 @@ describe('Weather with mocked API', () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toHaveProperty('temp');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', () => ({
|
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' }));
|
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret' }));
|
||||||
|
|
||||||
import { extractToken, authenticate, adminOnly } from '../../../src/middleware/auth';
|
import { extractToken, authenticate, adminOnly } from '../../../src/middleware/auth';
|
||||||
|
import { db } from '../../../src/db/database';
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
function makeReq(overrides: {
|
function makeReq(overrides: {
|
||||||
@@ -82,6 +84,56 @@ describe('authenticate', () => {
|
|||||||
expect(next).not.toHaveBeenCalled();
|
expect(next).not.toHaveBeenCalled();
|
||||||
expect(status).toHaveBeenCalledWith(401);
|
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 ─────────────────────────────────────────────────────────────────
|
// ── 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,7 @@ import {
|
|||||||
getAdminGlobalPref,
|
getAdminGlobalPref,
|
||||||
getActiveChannels,
|
getActiveChannels,
|
||||||
getAvailableChannels,
|
getAvailableChannels,
|
||||||
|
isWebhookConfigured,
|
||||||
} from '../../../src/services/notificationPreferencesService';
|
} from '../../../src/services/notificationPreferencesService';
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -316,3 +317,19 @@ describe('setAdminPreferences', () => {
|
|||||||
expect(row?.value).toBe('1');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
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
|
// Mock database — permissions module queries app_settings at runtime
|
||||||
vi.mock('../../../src/db/database', () => ({
|
vi.mock('../../../src/db/database', () => ({
|
||||||
db: {
|
db: {
|
||||||
prepare: () => ({
|
prepare: () => ({
|
||||||
all: () => [], // no custom permissions → fall back to defaults
|
all: () => dbRows, // no custom permissions → fall back to defaults
|
||||||
run: vi.fn(),
|
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('permissions', () => {
|
||||||
describe('checkPermission — admin bypass', () => {
|
describe('checkPermission — admin bypass', () => {
|
||||||
@@ -80,4 +85,30 @@ describe('permissions', () => {
|
|||||||
expect(getPermissionLevel('nonexistent_action')).toBe('trip_owner');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
// Prevent the module-level setInterval from running during tests
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -8,7 +8,14 @@ vi.stubGlobal('fetch', vi.fn());
|
|||||||
|
|
||||||
afterAll(() => vi.unstubAllGlobals());
|
afterAll(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
import { estimateCondition, cacheKey } from '../../../src/services/weatherService';
|
import {
|
||||||
|
estimateCondition,
|
||||||
|
cacheKey,
|
||||||
|
getWeather,
|
||||||
|
getDetailedWeather,
|
||||||
|
ApiError,
|
||||||
|
type WeatherResult,
|
||||||
|
} from '../../../src/services/weatherService';
|
||||||
|
|
||||||
// ── estimateCondition ────────────────────────────────────────────────────────
|
// ── estimateCondition ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -105,3 +112,585 @@ describe('cacheKey', () => {
|
|||||||
expect(cacheKey('0', '0', 'climate')).toBe('0.00_0.00_climate');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
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
|
// Mock dns/promises to avoid real DNS lookups in unit tests
|
||||||
vi.mock('dns/promises', () => ({
|
vi.mock('dns/promises', () => ({
|
||||||
default: { lookup: vi.fn() },
|
default: { lookup: vi.fn() },
|
||||||
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 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);
|
const mockLookup = vi.mocked(dns.lookup);
|
||||||
|
|
||||||
@@ -142,4 +156,94 @@ describe('checkSsrf', () => {
|
|||||||
expect(result.allowed).toBe(false);
|
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 }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* WebSocket connection tests.
|
* 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.
|
* 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 http from 'http';
|
||||||
|
import request from 'supertest';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
|
import { broadcastToUser, getOnlineUserIds } from '../../src/websocket';
|
||||||
|
|
||||||
const { testDb, dbMock } = vi.hoisted(() => {
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
@@ -44,6 +46,7 @@ import { createTables } from '../../src/db/schema';
|
|||||||
import { runMigrations } from '../../src/db/migrations';
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
import { resetTestDb } from '../helpers/test-db';
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
import { createUser, createTrip } from '../helpers/factories';
|
import { createUser, createTrip } from '../helpers/factories';
|
||||||
|
import { authCookie } from '../helpers/auth';
|
||||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
import { setupWebSocket } from '../../src/websocket';
|
import { setupWebSocket } from '../../src/websocket';
|
||||||
import { createEphemeralToken } from '../../src/services/ephemeralTokens';
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,3 +12,13 @@ sonar.test.inclusions=server/tests/**/*.ts
|
|||||||
|
|
||||||
# Coverage — path relative to repo root
|
# Coverage — path relative to repo root
|
||||||
sonar.javascript.lcov.reportPaths=server/coverage/lcov.info
|
sonar.javascript.lcov.reportPaths=server/coverage/lcov.info
|
||||||
|
|
||||||
|
# Exclude client from coverage requirements (no frontend test suite yet)
|
||||||
|
# Exclude infrastructure/bootstrap files that are always mocked or not unit-testable
|
||||||
|
sonar.coverage.exclusions=\
|
||||||
|
client/**,\
|
||||||
|
server/src/index.ts,\
|
||||||
|
server/src/db/database.ts,\
|
||||||
|
server/src/db/seeds.ts,\
|
||||||
|
server/src/demo/**,\
|
||||||
|
server/src/config.ts
|
||||||
Reference in New Issue
Block a user