Add comprehensive backend test suite (#339)

* add test suite, mostly covers integration testing, tests are only backend side

* workflow runs the correct script

* workflow runs the correct script

* workflow runs the correct script

* unit tests incoming

* Fix multer silent rejections and error handler info leak

- Revert cb(null, false) to cb(new Error(...)) in auth.ts, collab.ts,
  and files.ts so invalid uploads return an error instead of silently
  dropping the file
- Error handler in app.ts now always returns 500 / "Internal server
  error" instead of forwarding err.message to the client

* Use statusCode consistently for multer errors and error handler

- Error handler in app.ts reads err.statusCode to forward the correct
  HTTP status while keeping the response body generic
This commit is contained in:
Julien G.
2026-04-03 13:17:53 +02:00
committed by GitHub
parent d48714d17a
commit 905c7d460b
74 changed files with 12821 additions and 311 deletions

View File

@@ -0,0 +1,353 @@
/**
* Admin integration tests.
* Covers ADMIN-001 to ADMIN-022.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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, createInviteToken } 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();
});
// ─────────────────────────────────────────────────────────────────────────────
// Access control
// ─────────────────────────────────────────────────────────────────────────────
describe('Admin access control', () => {
it('ADMIN-022 — non-admin cannot access admin routes', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/admin/users')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(403);
});
it('ADMIN-022 — unauthenticated request returns 401', async () => {
const res = await request(app).get('/api/admin/users');
expect(res.status).toBe(401);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// User management
// ─────────────────────────────────────────────────────────────────────────────
describe('Admin user management', () => {
it('ADMIN-001 — GET /admin/users lists all users', async () => {
const { user: admin } = createAdmin(testDb);
createUser(testDb);
createUser(testDb);
const res = await request(app)
.get('/api/admin/users')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.users.length).toBeGreaterThanOrEqual(3);
});
it('ADMIN-002 — POST /admin/users creates a user', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.post('/api/admin/users')
.set('Cookie', authCookie(admin.id))
.send({ username: 'newuser', email: 'newuser@example.com', password: 'Secure1234!', role: 'user' });
expect(res.status).toBe(201);
expect(res.body.user.email).toBe('newuser@example.com');
});
it('ADMIN-003 — POST /admin/users with duplicate email returns 409', async () => {
const { user: admin } = createAdmin(testDb);
const { user: existing } = createUser(testDb);
const res = await request(app)
.post('/api/admin/users')
.set('Cookie', authCookie(admin.id))
.send({ username: 'duplicate', email: existing.email, password: 'Secure1234!' });
expect(res.status).toBe(409);
});
it('ADMIN-004 — PUT /admin/users/:id updates user', async () => {
const { user: admin } = createAdmin(testDb);
const { user } = createUser(testDb);
const res = await request(app)
.put(`/api/admin/users/${user.id}`)
.set('Cookie', authCookie(admin.id))
.send({ username: 'updated_username' });
expect(res.status).toBe(200);
expect(res.body.user.username).toBe('updated_username');
});
it('ADMIN-005 — DELETE /admin/users/:id removes user', async () => {
const { user: admin } = createAdmin(testDb);
const { user } = createUser(testDb);
const res = await request(app)
.delete(`/api/admin/users/${user.id}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('ADMIN-006 — admin cannot delete their own account', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.delete(`/api/admin/users/${admin.id}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(400);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// System stats
// ─────────────────────────────────────────────────────────────────────────────
describe('System stats', () => {
it('ADMIN-007 — GET /admin/stats returns system statistics', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/admin/stats')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('totalUsers');
expect(res.body).toHaveProperty('totalTrips');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Permissions
// ─────────────────────────────────────────────────────────────────────────────
describe('Permissions management', () => {
it('ADMIN-008 — GET /admin/permissions returns permission config', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/admin/permissions')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('permissions');
expect(Array.isArray(res.body.permissions)).toBe(true);
});
it('ADMIN-008 — PUT /admin/permissions updates permissions', async () => {
const { user: admin } = createAdmin(testDb);
const getRes = await request(app)
.get('/api/admin/permissions')
.set('Cookie', authCookie(admin.id));
const currentPerms = getRes.body;
const res = await request(app)
.put('/api/admin/permissions')
.set('Cookie', authCookie(admin.id))
.send({ permissions: currentPerms });
expect(res.status).toBe(200);
});
it('ADMIN-008 — PUT /admin/permissions without object returns 400', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.put('/api/admin/permissions')
.set('Cookie', authCookie(admin.id))
.send({ permissions: null });
expect(res.status).toBe(400);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Audit log
// ─────────────────────────────────────────────────────────────────────────────
describe('Audit log', () => {
it('ADMIN-009 — GET /admin/audit-log returns log entries', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/admin/audit-log')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.entries)).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Addon management
// ─────────────────────────────────────────────────────────────────────────────
describe('Addon management', () => {
it('ADMIN-011 — PUT /admin/addons/:id disables an addon', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.put('/api/admin/addons/atlas')
.set('Cookie', authCookie(admin.id))
.send({ enabled: false });
expect(res.status).toBe(200);
});
it('ADMIN-012 — PUT /admin/addons/:id re-enables an addon', async () => {
const { user: admin } = createAdmin(testDb);
await request(app)
.put('/api/admin/addons/atlas')
.set('Cookie', authCookie(admin.id))
.send({ enabled: false });
const res = await request(app)
.put('/api/admin/addons/atlas')
.set('Cookie', authCookie(admin.id))
.send({ enabled: true });
expect(res.status).toBe(200);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Invite tokens
// ─────────────────────────────────────────────────────────────────────────────
describe('Invite token management', () => {
it('ADMIN-013 — POST /admin/invites creates an invite token', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.post('/api/admin/invites')
.set('Cookie', authCookie(admin.id))
.send({ max_uses: 5 });
expect(res.status).toBe(201);
expect(res.body.invite.token).toBeDefined();
});
it('ADMIN-014 — DELETE /admin/invites/:id removes invite', async () => {
const { user: admin } = createAdmin(testDb);
const invite = createInviteToken(testDb, { created_by: admin.id });
const res = await request(app)
.delete(`/api/admin/invites/${invite.id}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Packing templates
// ─────────────────────────────────────────────────────────────────────────────
describe('Packing templates', () => {
it('ADMIN-015 — POST /admin/packing-templates creates a template', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.post('/api/admin/packing-templates')
.set('Cookie', authCookie(admin.id))
.send({ name: 'Beach Trip', description: 'Beach essentials' });
expect(res.status).toBe(201);
expect(res.body.template.name).toBe('Beach Trip');
});
it('ADMIN-016 — DELETE /admin/packing-templates/:id removes template', async () => {
const { user: admin } = createAdmin(testDb);
const create = await request(app)
.post('/api/admin/packing-templates')
.set('Cookie', authCookie(admin.id))
.send({ name: 'Temp Template' });
const templateId = create.body.template.id;
const res = await request(app)
.delete(`/api/admin/packing-templates/${templateId}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bag tracking
// ─────────────────────────────────────────────────────────────────────────────
describe('Bag tracking', () => {
it('ADMIN-017 — PUT /admin/bag-tracking toggles bag tracking', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.put('/api/admin/bag-tracking')
.set('Cookie', authCookie(admin.id))
.send({ enabled: true });
expect(res.status).toBe(200);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// JWT rotation
// ─────────────────────────────────────────────────────────────────────────────
describe('JWT rotation', () => {
it('ADMIN-018 — POST /admin/rotate-jwt-secret rotates the JWT secret', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.post('/api/admin/rotate-jwt-secret')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});

View File

@@ -0,0 +1,343 @@
/**
* Day Assignments integration tests.
* Covers ASSIGN-001 to ASSIGN-009.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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, createDay, createPlace, addTripMember } 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();
});
// Helper: create a trip with a day and a place, return all three
function setupAssignmentFixtures(userId: number) {
const trip = createTrip(testDb, userId);
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
const place = createPlace(testDb, trip.id, { name: 'Test Place' });
return { trip, day, place };
}
// ─────────────────────────────────────────────────────────────────────────────
// Create assignment
// ─────────────────────────────────────────────────────────────────────────────
describe('Create assignment', () => {
it('ASSIGN-001 — POST creates assignment linking place to day', async () => {
const { user } = createUser(testDb);
const { trip, day, place } = setupAssignmentFixtures(user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id });
expect(res.status).toBe(201);
// The assignment has an embedded place object, not a top-level place_id
expect(res.body.assignment.place.id).toBe(place.id);
expect(res.body.assignment.day_id).toBe(day.id);
});
it('ASSIGN-001 — POST with notes stores notes on assignment', async () => {
const { user } = createUser(testDb);
const { trip, day, place } = setupAssignmentFixtures(user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id, notes: 'Book table in advance' });
expect(res.status).toBe(201);
expect(res.body.assignment.notes).toBe('Book table in advance');
});
it('ASSIGN-001 — POST with non-existent place returns 404', async () => {
const { user } = createUser(testDb);
const { trip, day } = setupAssignmentFixtures(user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: 99999 });
expect(res.status).toBe(404);
});
it('ASSIGN-001 — POST with non-existent day returns 404', async () => {
const { user } = createUser(testDb);
const { trip, place } = setupAssignmentFixtures(user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/days/99999/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id });
expect(res.status).toBe(404);
});
it('ASSIGN-006 — non-member cannot create assignment', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const { trip, day, place } = setupAssignmentFixtures(owner.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(other.id))
.send({ place_id: place.id });
expect(res.status).toBe(404);
});
it('ASSIGN-006 — trip member can create assignment', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const { trip, day, place } = setupAssignmentFixtures(owner.id);
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(member.id))
.send({ place_id: place.id });
expect(res.status).toBe(201);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// List assignments
// ─────────────────────────────────────────────────────────────────────────────
describe('List assignments', () => {
it('ASSIGN-002 — GET /api/trips/:tripId/days/:dayId/assignments returns assignments for the day', async () => {
const { user } = createUser(testDb);
const { trip, day, place } = setupAssignmentFixtures(user.id);
await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id });
const res = await request(app)
.get(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.assignments).toHaveLength(1);
// Assignments have an embedded place object
expect(res.body.assignments[0].place.id).toBe(place.id);
});
it('ASSIGN-002 — returns empty array when no assignments exist', async () => {
const { user } = createUser(testDb);
const { trip, day } = setupAssignmentFixtures(user.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.assignments).toHaveLength(0);
});
it('ASSIGN-006 — non-member cannot list assignments', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const { trip, day } = setupAssignmentFixtures(owner.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(other.id));
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Delete assignment
// ─────────────────────────────────────────────────────────────────────────────
describe('Delete assignment', () => {
it('ASSIGN-004 — DELETE removes assignment', async () => {
const { user } = createUser(testDb);
const { trip, day, place } = setupAssignmentFixtures(user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id });
const assignmentId = create.body.assignment.id;
const del = await request(app)
.delete(`/api/trips/${trip.id}/days/${day.id}/assignments/${assignmentId}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
// Verify it's gone
const list = await request(app)
.get(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id));
expect(list.body.assignments).toHaveLength(0);
});
it('ASSIGN-004 — DELETE returns 404 for non-existent assignment', async () => {
const { user } = createUser(testDb);
const { trip, day } = setupAssignmentFixtures(user.id);
const res = await request(app)
.delete(`/api/trips/${trip.id}/days/${day.id}/assignments/99999`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Reorder assignments
// ─────────────────────────────────────────────────────────────────────────────
describe('Reorder assignments', () => {
it('ASSIGN-007 — PUT /reorder reorders assignments within a day', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
const place1 = createPlace(testDb, trip.id, { name: 'Place A' });
const place2 = createPlace(testDb, trip.id, { name: 'Place B' });
const a1 = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place1.id });
const a2 = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place2.id });
const reorder = await request(app)
.put(`/api/trips/${trip.id}/days/${day.id}/assignments/reorder`)
.set('Cookie', authCookie(user.id))
.send({ orderedIds: [a2.body.assignment.id, a1.body.assignment.id] });
expect(reorder.status).toBe(200);
expect(reorder.body.success).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Move assignment
// ─────────────────────────────────────────────────────────────────────────────
describe('Move assignment', () => {
it('ASSIGN-008 — PUT /move transfers assignment to a different day', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day1 = createDay(testDb, trip.id, { date: '2025-06-01' });
const day2 = createDay(testDb, trip.id, { date: '2025-06-02' });
const place = createPlace(testDb, trip.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/days/${day1.id}/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id });
const assignmentId = create.body.assignment.id;
const move = await request(app)
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/move`)
.set('Cookie', authCookie(user.id))
.send({ new_day_id: day2.id, order_index: 0 });
expect(move.status).toBe(200);
expect(move.body.assignment.day_id).toBe(day2.id);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Participants
// ─────────────────────────────────────────────────────────────────────────────
describe('Assignment participants', () => {
it('ASSIGN-005 — PUT /participants updates participant list', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const { trip, day, place } = setupAssignmentFixtures(user.id);
addTripMember(testDb, trip.id, member.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id });
const assignmentId = create.body.assignment.id;
const update = await request(app)
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/participants`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: [user.id, member.id] });
expect(update.status).toBe(200);
const getParticipants = await request(app)
.get(`/api/trips/${trip.id}/assignments/${assignmentId}/participants`)
.set('Cookie', authCookie(user.id));
expect(getParticipants.status).toBe(200);
expect(getParticipants.body.participants).toHaveLength(2);
});
it('ASSIGN-009 — PUT /time updates assignment time fields', async () => {
const { user } = createUser(testDb);
const { trip, day, place } = setupAssignmentFixtures(user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id });
const assignmentId = create.body.assignment.id;
const update = await request(app)
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/time`)
.set('Cookie', authCookie(user.id))
.send({ place_time: '14:00', end_time: '16:00' });
expect(update.status).toBe(200);
// Time is embedded under assignment.place.place_time (COALESCEd from assignment_time)
expect(update.body.assignment.place.place_time).toBe('14:00');
expect(update.body.assignment.place.end_time).toBe('16:00');
});
});

View File

@@ -0,0 +1,204 @@
/**
* Atlas integration tests.
* Covers ATLAS-001 to ATLAS-008.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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('Atlas stats', () => {
it('ATLAS-001 — GET /api/atlas/stats returns stats object', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/addons/atlas/stats')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('countries');
expect(res.body).toHaveProperty('stats');
});
it('ATLAS-002 — GET /api/atlas/country/:code returns places in country', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/addons/atlas/country/FR')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.places)).toBe(true);
});
});
describe('Mark/unmark country', () => {
it('ATLAS-003 — POST /country/:code/mark marks country as visited', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/addons/atlas/country/DE/mark')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Verify it appears in visited countries
const stats = await request(app)
.get('/api/addons/atlas/stats')
.set('Cookie', authCookie(user.id));
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
expect(codes).toContain('DE');
});
it('ATLAS-004 — DELETE /country/:code/mark unmarks country', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/country/IT/mark')
.set('Cookie', authCookie(user.id));
const res = await request(app)
.delete('/api/addons/atlas/country/IT/mark')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
describe('Bucket list', () => {
it('ATLAS-005 — POST /bucket-list creates a bucket list item', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/addons/atlas/bucket-list')
.set('Cookie', authCookie(user.id))
.send({ name: 'Machu Picchu', country_code: 'PE', lat: -13.1631, lng: -72.5450 });
expect(res.status).toBe(201);
expect(res.body.item.name).toBe('Machu Picchu');
});
it('ATLAS-005 — POST without name returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/addons/atlas/bucket-list')
.set('Cookie', authCookie(user.id))
.send({ country_code: 'JP' });
expect(res.status).toBe(400);
});
it('ATLAS-006 — GET /bucket-list returns items', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/bucket-list')
.set('Cookie', authCookie(user.id))
.send({ name: 'Santorini', country_code: 'GR' });
const res = await request(app)
.get('/api/addons/atlas/bucket-list')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.items).toHaveLength(1);
});
it('ATLAS-007 — PUT /bucket-list/:id updates item', async () => {
const { user } = createUser(testDb);
const create = await request(app)
.post('/api/addons/atlas/bucket-list')
.set('Cookie', authCookie(user.id))
.send({ name: 'Old Name' });
const id = create.body.item.id;
const res = await request(app)
.put(`/api/addons/atlas/bucket-list/${id}`)
.set('Cookie', authCookie(user.id))
.send({ name: 'New Name', notes: 'Updated' });
expect(res.status).toBe(200);
expect(res.body.item.name).toBe('New Name');
});
it('ATLAS-008 — DELETE /bucket-list/:id removes item', async () => {
const { user } = createUser(testDb);
const create = await request(app)
.post('/api/addons/atlas/bucket-list')
.set('Cookie', authCookie(user.id))
.send({ name: 'Tokyo' });
const id = create.body.item.id;
const del = await request(app)
.delete(`/api/addons/atlas/bucket-list/${id}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
const list = await request(app)
.get('/api/addons/atlas/bucket-list')
.set('Cookie', authCookie(user.id));
expect(list.body.items).toHaveLength(0);
});
it('ATLAS-008 — DELETE non-existent item returns 404', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.delete('/api/addons/atlas/bucket-list/99999')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
});

View File

@@ -0,0 +1,480 @@
/**
* Authentication integration tests.
* Covers AUTH-001 to AUTH-022, AUTH-028 to AUTH-030.
* 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.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import { authenticator } from 'otplib';
// ─────────────────────────────────────────────────────────────────────────────
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
// ─────────────────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: number) => {
const place: any = db.prepare(`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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, createUserWithMfa, createInviteToken } from '../helpers/factories';
import { authCookie, authHeader } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
// Reset rate limiter state between tests so they don't interfere
loginAttempts.clear();
mfaAttempts.clear();
});
afterAll(() => {
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// Login
// ─────────────────────────────────────────────────────────────────────────────
describe('Login', () => {
it('AUTH-001 — successful login returns 200, user object, and trek_session cookie', async () => {
const { user, password } = createUser(testDb);
const res = await request(app).post('/api/auth/login').send({ email: user.email, password });
expect(res.status).toBe(200);
expect(res.body.user).toBeDefined();
expect(res.body.user.email).toBe(user.email);
expect(res.body.user.password_hash).toBeUndefined();
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
? res.headers['set-cookie']
: [res.headers['set-cookie']];
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
});
it('AUTH-002 — wrong password returns 401 with generic message', async () => {
const { user } = createUser(testDb);
const res = await request(app).post('/api/auth/login').send({ email: user.email, password: 'WrongPass1!' });
expect(res.status).toBe(401);
expect(res.body.error).toContain('Invalid email or password');
});
it('AUTH-003 — non-existent email returns 401 with same generic message (no user enumeration)', async () => {
const res = await request(app).post('/api/auth/login').send({ email: 'nobody@example.com', password: 'SomePass1!' });
expect(res.status).toBe(401);
// Must be same message as wrong-password to avoid email enumeration
expect(res.body.error).toContain('Invalid email or password');
});
it('AUTH-013 — POST /api/auth/logout clears session cookie', async () => {
const res = await request(app).post('/api/auth/logout');
expect(res.status).toBe(200);
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
? res.headers['set-cookie']
: (res.headers['set-cookie'] ? [res.headers['set-cookie']] : []);
const sessionCookie = cookies.find((c: string) => c.includes('trek_session'));
expect(sessionCookie).toBeDefined();
expect(sessionCookie).toMatch(/expires=Thu, 01 Jan 1970|Max-Age=0/i);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Registration
// ─────────────────────────────────────────────────────────────────────────────
describe('Registration', () => {
it('AUTH-005 — first user registration creates admin role and returns 201 + cookie', async () => {
const res = await request(app).post('/api/auth/register').send({
username: 'firstadmin',
email: 'admin@example.com',
password: 'Str0ng!Pass',
});
expect(res.status).toBe(201);
expect(res.body.user.role).toBe('admin');
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
? res.headers['set-cookie']
: [res.headers['set-cookie']];
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
});
it('AUTH-006 — registration with weak password is rejected', async () => {
const res = await request(app).post('/api/auth/register').send({
username: 'weakpwduser',
email: 'weak@example.com',
password: 'short',
});
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('AUTH-007 — registration with common password is rejected', async () => {
const res = await request(app).post('/api/auth/register').send({
username: 'commonpwd',
email: 'common@example.com',
password: 'Password1', // 'password1' is in the COMMON_PASSWORDS set
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/common/i);
});
it('AUTH-008 — registration with duplicate email returns 409', async () => {
createUser(testDb, { email: 'taken@example.com' });
const res = await request(app).post('/api/auth/register').send({
username: 'newuser',
email: 'taken@example.com',
password: 'Str0ng!Pass',
});
expect(res.status).toBe(409);
});
it('AUTH-009 — registration disabled by admin returns 403', async () => {
createUser(testDb);
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
const res = await request(app).post('/api/auth/register').send({
username: 'blocked',
email: 'blocked@example.com',
password: 'Str0ng!Pass',
});
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/disabled/i);
});
it('AUTH-010 — registration with valid invite token succeeds even when registration disabled', async () => {
const { user: admin } = createAdmin(testDb);
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
const invite = createInviteToken(testDb, { max_uses: 1, created_by: admin.id });
const res = await request(app).post('/api/auth/register').send({
username: 'invited',
email: 'invited@example.com',
password: 'Str0ng!Pass',
invite_token: invite.token,
});
expect(res.status).toBe(201);
const row = testDb.prepare('SELECT used_count FROM invite_tokens WHERE id = ?').get(invite.id) as { used_count: number };
expect(row.used_count).toBe(1);
});
it('AUTH-011 — GET /api/auth/invite/:token with expired token returns 410', async () => {
const { user: admin } = createAdmin(testDb);
const yesterday = new Date(Date.now() - 86_400_000).toISOString();
const invite = createInviteToken(testDb, { expires_at: yesterday, created_by: admin.id });
const res = await request(app).get(`/api/auth/invite/${invite.token}`);
expect(res.status).toBe(410);
expect(res.body.error).toMatch(/expired/i);
});
it('AUTH-012 — GET /api/auth/invite/:token with exhausted token returns 410', async () => {
const { user: admin } = createAdmin(testDb);
const invite = createInviteToken(testDb, { max_uses: 1, created_by: admin.id });
// Mark as exhausted
testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id);
const res = await request(app).get(`/api/auth/invite/${invite.token}`);
expect(res.status).toBe(410);
expect(res.body.error).toMatch(/fully used/i);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Session / Me
// ─────────────────────────────────────────────────────────────────────────────
describe('Session', () => {
it('AUTH-014 — GET /api/auth/me without session returns 401 AUTH_REQUIRED', async () => {
const res = await request(app).get('/api/auth/me');
expect(res.status).toBe(401);
expect(res.body.code).toBe('AUTH_REQUIRED');
});
it('AUTH-014 — GET /api/auth/me with valid cookie returns safe user object', async () => {
const { user } = createUser(testDb);
const res = await request(app).get('/api/auth/me').set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.user.id).toBe(user.id);
expect(res.body.user.email).toBe(user.email);
expect(res.body.user.password_hash).toBeUndefined();
expect(res.body.user.mfa_secret).toBeUndefined();
});
it('AUTH-021 — user with must_change_password=1 sees the flag in their profile', async () => {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET must_change_password = 1 WHERE id = ?').run(user.id);
const res = await request(app).get('/api/auth/me').set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.user.must_change_password).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// App Config (AUTH-028)
// ─────────────────────────────────────────────────────────────────────────────
describe('App config', () => {
it('AUTH-028 — GET /api/auth/app-config returns expected flags', async () => {
const res = await request(app).get('/api/auth/app-config');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('allow_registration');
expect(res.body).toHaveProperty('oidc_configured');
expect(res.body).toHaveProperty('demo_mode');
expect(res.body).toHaveProperty('has_users');
expect(res.body).toHaveProperty('setup_complete');
});
it('AUTH-028 — allow_registration is false after admin disables it', async () => {
createUser(testDb);
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
const res = await request(app).get('/api/auth/app-config');
expect(res.status).toBe(200);
expect(res.body.allow_registration).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Demo Login (AUTH-022)
// ─────────────────────────────────────────────────────────────────────────────
describe('Demo login', () => {
it('AUTH-022 — POST /api/auth/demo-login without DEMO_MODE returns 404', async () => {
delete process.env.DEMO_MODE;
const res = await request(app).post('/api/auth/demo-login');
expect(res.status).toBe(404);
});
it('AUTH-022 — POST /api/auth/demo-login with DEMO_MODE and demo user returns 200 + cookie', async () => {
testDb.prepare(
"INSERT INTO users (username, email, password_hash, role) VALUES ('demo', 'demo@trek.app', 'x', 'user')"
).run();
process.env.DEMO_MODE = 'true';
try {
const res = await request(app).post('/api/auth/demo-login');
expect(res.status).toBe(200);
expect(res.body.user.email).toBe('demo@trek.app');
} finally {
delete process.env.DEMO_MODE;
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// MFA (AUTH-015 to AUTH-019)
// ─────────────────────────────────────────────────────────────────────────────
describe('MFA', () => {
it('AUTH-015 — POST /api/auth/mfa/setup returns secret and QR data URL', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/auth/mfa/setup')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.secret).toBeDefined();
expect(res.body.otpauth_url).toContain('otpauth://');
expect(res.body.qr_data_url).toMatch(/^data:image/);
});
it('AUTH-015 — POST /api/auth/mfa/enable with valid TOTP code enables MFA', async () => {
const { user } = createUser(testDb);
const setupRes = await request(app)
.post('/api/auth/mfa/setup')
.set('Cookie', authCookie(user.id));
expect(setupRes.status).toBe(200);
const enableRes = await request(app)
.post('/api/auth/mfa/enable')
.set('Cookie', authCookie(user.id))
.send({ code: authenticator.generate(setupRes.body.secret) });
expect(enableRes.status).toBe(200);
expect(enableRes.body.mfa_enabled).toBe(true);
expect(Array.isArray(enableRes.body.backup_codes)).toBe(true);
});
it('AUTH-016 — login with MFA-enabled account returns mfa_required + mfa_token', async () => {
const { user, password } = createUserWithMfa(testDb);
const loginRes = await request(app)
.post('/api/auth/login')
.send({ email: user.email, password });
expect(loginRes.status).toBe(200);
expect(loginRes.body.mfa_required).toBe(true);
expect(typeof loginRes.body.mfa_token).toBe('string');
});
it('AUTH-016 — POST /api/auth/mfa/verify-login with valid code completes login', async () => {
const { user, password, totpSecret } = createUserWithMfa(testDb);
const loginRes = await request(app)
.post('/api/auth/login')
.send({ email: user.email, password });
const { mfa_token } = loginRes.body;
const verifyRes = await request(app)
.post('/api/auth/mfa/verify-login')
.send({ mfa_token, code: authenticator.generate(totpSecret) });
expect(verifyRes.status).toBe(200);
expect(verifyRes.body.user).toBeDefined();
const cookies: string[] = Array.isArray(verifyRes.headers['set-cookie'])
? verifyRes.headers['set-cookie']
: [verifyRes.headers['set-cookie']];
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
});
it('AUTH-017 — verify-login with invalid TOTP code returns 401', async () => {
const { user, password } = createUserWithMfa(testDb);
const loginRes = await request(app)
.post('/api/auth/login')
.send({ email: user.email, password });
const verifyRes = await request(app)
.post('/api/auth/mfa/verify-login')
.send({ mfa_token: loginRes.body.mfa_token, code: '000000' });
expect(verifyRes.status).toBe(401);
expect(verifyRes.body.error).toMatch(/invalid/i);
});
it('AUTH-019 — disable MFA with valid password and TOTP code', async () => {
const { user, password, totpSecret } = createUserWithMfa(testDb);
const disableRes = await request(app)
.post('/api/auth/mfa/disable')
.set('Cookie', authCookie(user.id))
.send({ password, code: authenticator.generate(totpSecret) });
expect(disableRes.status).toBe(200);
expect(disableRes.body.mfa_enabled).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Forced MFA Policy (AUTH-020)
// ─────────────────────────────────────────────────────────────────────────────
describe('Forced MFA policy', () => {
it('AUTH-020 — non-MFA user is blocked (403 MFA_REQUIRED) when require_mfa is true', async () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
// mfaPolicy checks Authorization: Bearer header
const res = await request(app).get('/api/trips').set(authHeader(user.id));
expect(res.status).toBe(403);
expect(res.body.code).toBe('MFA_REQUIRED');
});
it('AUTH-020 — /api/auth/me and MFA setup endpoints are exempt from require_mfa', async () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
const meRes = await request(app).get('/api/auth/me').set(authHeader(user.id));
expect(meRes.status).toBe(200);
const setupRes = await request(app).post('/api/auth/mfa/setup').set(authHeader(user.id));
expect(setupRes.status).toBe(200);
});
it('AUTH-020 — MFA-enabled user passes through require_mfa policy', async () => {
const { user } = createUserWithMfa(testDb);
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
const res = await request(app).get('/api/trips').set(authHeader(user.id));
expect(res.status).toBe(200);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Short-lived tokens (AUTH-029, AUTH-030)
// ─────────────────────────────────────────────────────────────────────────────
describe('Short-lived tokens', () => {
it('AUTH-029 — POST /api/auth/ws-token returns a single-use token', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/auth/ws-token')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
expect(res.body.token.length).toBeGreaterThan(0);
});
it('AUTH-030 — POST /api/auth/resource-token returns a single-use token', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/auth/resource-token')
.set('Cookie', authCookie(user.id))
.send({ purpose: 'download' });
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
expect(res.body.token.length).toBeGreaterThan(0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Rate limiting (AUTH-004, AUTH-018) — placed last
// ─────────────────────────────────────────────────────────────────────────────
describe('Rate limiting', () => {
it('AUTH-004 — login endpoint rate-limits after 10 attempts from the same IP', async () => {
// beforeEach has cleared loginAttempts; we fill up exactly to the limit
let lastStatus = 0;
for (let i = 0; i <= 10; i++) {
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'ratelimit@example.com', password: 'wrong' });
lastStatus = res.status;
if (lastStatus === 429) break;
}
expect(lastStatus).toBe(429);
});
it('AUTH-018 — MFA verify-login endpoint rate-limits after 5 attempts', async () => {
let lastStatus = 0;
for (let i = 0; i <= 5; i++) {
const res = await request(app)
.post('/api/auth/mfa/verify-login')
.send({ mfa_token: 'badtoken', code: '000000' });
lastStatus = res.status;
if (lastStatus === 429) break;
}
expect(lastStatus).toBe(429);
});
});

View File

@@ -0,0 +1,175 @@
/**
* Backup integration tests.
* Covers BACKUP-001 to BACKUP-008.
*
* Note: createBackup() is async and creates real files.
* These tests run in test env and may not have a full DB file to zip,
* but the service should handle gracefully.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
// Mock filesystem-dependent service functions to avoid real disk I/O in tests
vi.mock('../../src/services/backupService', async () => {
const actual = await vi.importActual<typeof import('../../src/services/backupService')>('../../src/services/backupService');
return {
...actual,
createBackup: vi.fn().mockResolvedValue({
filename: 'backup-2026-04-03T06-00-00.zip',
size: 1024,
sizeText: '1.0 KB',
created_at: new Date().toISOString(),
}),
updateAutoSettings: vi.fn().mockReturnValue({
enabled: false,
interval: 'daily',
keep_days: 7,
hour: 2,
day_of_week: 0,
day_of_month: 1,
}),
};
});
import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createAdmin, 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('Backup access control', () => {
it('non-admin cannot access backup routes', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/backup/list')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(403);
});
});
describe('Backup list', () => {
it('BACKUP-001 — GET /backup/list returns backups array', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/backup/list')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.backups)).toBe(true);
});
});
describe('Backup creation', () => {
it('BACKUP-001 — POST /backup/create creates a backup', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.post('/api/backup/create')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.backup).toHaveProperty('filename');
expect(res.body.backup).toHaveProperty('size');
});
});
describe('Auto-backup settings', () => {
it('BACKUP-008 — GET /backup/auto-settings returns current config', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/backup/auto-settings')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('settings');
expect(res.body.settings).toHaveProperty('enabled');
});
it('BACKUP-008 — PUT /backup/auto-settings updates settings', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.put('/api/backup/auto-settings')
.set('Cookie', authCookie(admin.id))
.send({ enabled: false, interval: 'daily', keep_days: 7 });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('settings');
expect(res.body.settings).toHaveProperty('enabled');
expect(res.body.settings).toHaveProperty('interval');
});
});
describe('Backup security', () => {
it('BACKUP-007 — Download with path traversal filename is rejected', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/backup/download/../../etc/passwd')
.set('Cookie', authCookie(admin.id));
// Express normalises the URL before routing; path traversal gets resolved
// to a path that matches no route → 404
expect(res.status).toBe(404);
});
it('BACKUP-007 — Delete with path traversal filename is rejected', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.delete('/api/backup/../../../etc/passwd')
.set('Cookie', authCookie(admin.id));
// Express normalises the URL, stripping traversal → no route match → 404
expect(res.status).toBe(404);
});
});

View File

@@ -0,0 +1,286 @@
/**
* Budget Planner integration tests.
* Covers BUDGET-001 to BUDGET-010.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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, createBudgetItem, addTripMember } 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();
});
// ─────────────────────────────────────────────────────────────────────────────
// Create budget item
// ─────────────────────────────────────────────────────────────────────────────
describe('Create budget item', () => {
it('BUDGET-001 — POST creates budget item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/budget`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Flights', category: 'Transport', total_price: 500, currency: 'EUR' });
expect(res.status).toBe(201);
expect(res.body.item.name).toBe('Flights');
expect(res.body.item.total_price).toBe(500);
});
it('BUDGET-001 — POST without name returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/budget`)
.set('Cookie', authCookie(user.id))
.send({ category: 'Transport', total_price: 200 });
expect(res.status).toBe(400);
});
it('BUDGET-010 — non-member cannot create budget item', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/budget`)
.set('Cookie', authCookie(other.id))
.send({ name: 'Hotels', total_price: 300 });
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// List budget items
// ─────────────────────────────────────────────────────────────────────────────
describe('List budget items', () => {
it('BUDGET-002 — GET /api/trips/:tripId/budget returns all items', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createBudgetItem(testDb, trip.id, { name: 'Flight', total_price: 300 });
createBudgetItem(testDb, trip.id, { name: 'Hotel', total_price: 500 });
const res = await request(app)
.get(`/api/trips/${trip.id}/budget`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.items).toHaveLength(2);
});
it('BUDGET-002 — member can list budget items', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
createBudgetItem(testDb, trip.id, { name: 'Rental', total_price: 200 });
const res = await request(app)
.get(`/api/trips/${trip.id}/budget`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(200);
expect(res.body.items).toHaveLength(1);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Update budget item
// ─────────────────────────────────────────────────────────────────────────────
describe('Update budget item', () => {
it('BUDGET-003 — PUT updates budget item fields', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { name: 'Old Name', total_price: 100 });
const res = await request(app)
.put(`/api/trips/${trip.id}/budget/${item.id}`)
.set('Cookie', authCookie(user.id))
.send({ name: 'New Name', total_price: 250 });
expect(res.status).toBe(200);
expect(res.body.item.name).toBe('New Name');
expect(res.body.item.total_price).toBe(250);
});
it('BUDGET-003 — PUT 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}/budget/99999`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Updated' });
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Delete budget item
// ─────────────────────────────────────────────────────────────────────────────
describe('Delete budget item', () => {
it('BUDGET-004 — DELETE removes item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
const del = await request(app)
.delete(`/api/trips/${trip.id}/budget/${item.id}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
const list = await request(app)
.get(`/api/trips/${trip.id}/budget`)
.set('Cookie', authCookie(user.id));
expect(list.body.items).toHaveLength(0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Members
// ─────────────────────────────────────────────────────────────────────────────
describe('Budget item members', () => {
it('BUDGET-005 — PUT /members assigns members to budget item', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
const item = createBudgetItem(testDb, trip.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: [user.id, member.id] });
expect(res.status).toBe(200);
expect(res.body.members).toBeDefined();
});
it('BUDGET-005 — PUT /members with non-array user_ids returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: 'not-an-array' });
expect(res.status).toBe(400);
});
it('BUDGET-006 — PUT /members/:userId/paid toggles paid status', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
// Assign user as member first
await request(app)
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: [user.id] });
const res = await request(app)
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
.set('Cookie', authCookie(user.id))
.send({ paid: true });
expect(res.status).toBe(200);
expect(res.body.member).toBeDefined();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Summary & Settlement
// ─────────────────────────────────────────────────────────────────────────────
describe('Budget summary and settlement', () => {
it('BUDGET-007 — GET /summary/per-person returns per-person breakdown', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 });
const res = await request(app)
.get(`/api/trips/${trip.id}/budget/summary/per-person`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.summary)).toBe(true);
});
it('BUDGET-008 — GET /settlement returns settlement transactions', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/budget/settlement`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('balances');
expect(res.body).toHaveProperty('flows');
});
it('BUDGET-009 — settlement with no payers returns empty transactions', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Item with no members/payers assigned
createBudgetItem(testDb, trip.id, { name: 'Train', total_price: 40 });
const res = await request(app)
.get(`/api/trips/${trip.id}/budget/settlement`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
});
});

View File

@@ -0,0 +1,543 @@
/**
* Collab (notes, polls, messages, reactions) integration tests.
* Covers COLLAB-001 to COLLAB-027.
*
* Note: File upload to collab notes (COLLAB-005/006/007) requires physical file I/O.
* Link preview (COLLAB-025/026) would need fetch mocking — skipped here.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import path from 'path';
import fs from 'fs';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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';
const app: Application = createApp();
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
// Ensure uploads/files dir exists for collab file uploads
const uploadsDir = path.join(__dirname, '../../uploads/files');
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
});
afterAll(() => {
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// Collab Notes
// ─────────────────────────────────────────────────────────────────────────────
describe('Collab notes', () => {
it('COLLAB-001 — POST /collab/notes creates a note', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Packing Ideas', content: 'Bring sunscreen', category: 'Planning' });
expect(res.status).toBe(201);
expect(res.body.note.title).toBe('Packing Ideas');
expect(res.body.note.content).toBe('Bring sunscreen');
});
it('COLLAB-001 — POST without title returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ content: 'No title' });
expect(res.status).toBe(400);
});
it('COLLAB-001 — non-member cannot create collab note', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(other.id))
.send({ title: 'Sneaky note' });
expect(res.status).toBe(404);
});
it('COLLAB-002 — GET /collab/notes returns all notes', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Note A' });
await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Note B' });
const res = await request(app)
.get(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.notes).toHaveLength(2);
});
it('COLLAB-003 — PUT /collab/notes/:id updates a note', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Old Title', content: 'Old content' });
const noteId = create.body.note.id;
const res = await request(app)
.put(`/api/trips/${trip.id}/collab/notes/${noteId}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'New Title', content: 'New content', pinned: true });
expect(res.status).toBe(200);
expect(res.body.note.title).toBe('New Title');
expect(res.body.note.pinned).toBe(1);
});
it('COLLAB-003 — PUT non-existent note returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/collab/notes/99999`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Updated' });
expect(res.status).toBe(404);
});
it('COLLAB-004 — DELETE /collab/notes/:id removes note', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'To Delete' });
const noteId = create.body.note.id;
const del = await request(app)
.delete(`/api/trips/${trip.id}/collab/notes/${noteId}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
const list = await request(app)
.get(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id));
expect(list.body.notes).toHaveLength(0);
});
it('COLLAB-005 — POST /collab/notes/:id/files uploads a file to a note', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Note with file' });
const noteId = create.body.note.id;
const upload = await request(app)
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
.set('Cookie', authCookie(user.id))
.attach('file', FIXTURE_PDF);
expect(upload.status).toBe(201);
expect(upload.body.file).toBeDefined();
});
it('COLLAB-006 — uploading blocked extension to note is rejected', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Note' });
const noteId = create.body.note.id;
// Create a temp .svg file
const svgPath = path.join(uploadsDir, 'collab_blocked.svg');
fs.writeFileSync(svgPath, '<svg></svg>');
try {
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
.set('Cookie', authCookie(user.id))
.attach('file', svgPath);
expect(res.status).toBe(400);
} finally {
if (fs.existsSync(svgPath)) fs.unlinkSync(svgPath);
}
});
it('COLLAB-007 — DELETE /collab/notes/:noteId/files/:fileId removes file from note', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Note with file' });
const noteId = create.body.note.id;
const upload = await request(app)
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
.set('Cookie', authCookie(user.id))
.attach('file', FIXTURE_PDF);
const fileId = upload.body.file.id;
const del = await request(app)
.delete(`/api/trips/${trip.id}/collab/notes/${noteId}/files/${fileId}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Polls
// ─────────────────────────────────────────────────────────────────────────────
describe('Polls', () => {
it('COLLAB-008 — POST /collab/polls creates a poll', 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: 'Where to eat?', options: ['Pizza', 'Sushi', 'Tacos'] });
expect(res.status).toBe(201);
expect(res.body.poll.question).toBe('Where to eat?');
expect(res.body.poll.options).toHaveLength(3);
});
it('COLLAB-008 — POST without question 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({ options: ['A', 'B'] });
expect(res.status).toBe(400);
});
it('COLLAB-009 — GET /collab/polls returns polls', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await request(app)
.post(`/api/trips/${trip.id}/collab/polls`)
.set('Cookie', authCookie(user.id))
.send({ question: 'Beach or mountains?', options: ['Beach', 'Mountains'] });
const res = await request(app)
.get(`/api/trips/${trip.id}/collab/polls`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.polls).toHaveLength(1);
});
it('COLLAB-010 — POST /collab/polls/:id/vote casts a vote', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/polls`)
.set('Cookie', authCookie(user.id))
.send({ question: 'Restaurant?', options: ['Italian', 'French'] });
const pollId = create.body.poll.id;
const vote = await request(app)
.post(`/api/trips/${trip.id}/collab/polls/${pollId}/vote`)
.set('Cookie', authCookie(user.id))
.send({ option_index: 0 });
expect(vote.status).toBe(200);
expect(vote.body.poll).toBeDefined();
});
it('COLLAB-011 — PUT /collab/polls/:id/close closes a poll', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/polls`)
.set('Cookie', authCookie(user.id))
.send({ question: 'Hotel?', options: ['Budget', 'Luxury'] });
const pollId = create.body.poll.id;
const close = await request(app)
.put(`/api/trips/${trip.id}/collab/polls/${pollId}/close`)
.set('Cookie', authCookie(user.id));
expect(close.status).toBe(200);
expect(close.body.poll.is_closed).toBe(true);
});
it('COLLAB-012 — cannot vote on closed poll', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/polls`)
.set('Cookie', authCookie(user.id))
.send({ question: 'Closed?', options: ['Yes', 'No'] });
const pollId = create.body.poll.id;
await request(app)
.put(`/api/trips/${trip.id}/collab/polls/${pollId}/close`)
.set('Cookie', authCookie(user.id));
const vote = await request(app)
.post(`/api/trips/${trip.id}/collab/polls/${pollId}/vote`)
.set('Cookie', authCookie(user.id))
.send({ option_index: 0 });
expect(vote.status).toBe(400);
});
it('COLLAB-013 — DELETE /collab/polls/:id removes poll', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/polls`)
.set('Cookie', authCookie(user.id))
.send({ question: 'Delete me?', options: ['Yes', 'No'] });
const pollId = create.body.poll.id;
const del = await request(app)
.delete(`/api/trips/${trip.id}/collab/polls/${pollId}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Messages
// ─────────────────────────────────────────────────────────────────────────────
describe('Messages', () => {
it('COLLAB-014 — POST /collab/messages sends a message', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Hello, team!' });
expect(res.status).toBe(201);
expect(res.body.message.text).toBe('Hello, team!');
});
it('COLLAB-014 — POST without text returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: '' });
expect(res.status).toBe(400);
});
it('COLLAB-014 — non-member cannot send message', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(other.id))
.send({ text: 'Unauthorized' });
expect(res.status).toBe(404);
});
it('COLLAB-015 — GET /collab/messages returns messages in order', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'First message' });
await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Second message' });
const res = await request(app)
.get(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.messages.length).toBeGreaterThanOrEqual(2);
});
it('COLLAB-016 — POST /collab/messages with reply_to links reply', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const parent = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Original' });
const parentId = parent.body.message.id;
const reply = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Reply here', reply_to: parentId });
expect(reply.status).toBe(201);
expect(reply.body.message.reply_to).toBe(parentId);
});
it('COLLAB-017 — DELETE /collab/messages/:id removes own message', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msg = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Delete me' });
const msgId = msg.body.message.id;
const del = await request(app)
.delete(`/api/trips/${trip.id}/collab/messages/${msgId}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
});
it('COLLAB-017 — cannot delete another user\'s message', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const msg = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(owner.id))
.send({ text: 'Owner message' });
const msgId = msg.body.message.id;
const del = await request(app)
.delete(`/api/trips/${trip.id}/collab/messages/${msgId}`)
.set('Cookie', authCookie(member.id));
expect(del.status).toBe(403);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Reactions
// ─────────────────────────────────────────────────────────────────────────────
describe('Message reactions', () => {
it('COLLAB-018 — POST /collab/messages/:id/react adds a reaction', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msg = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'React to me' });
const msgId = msg.body.message.id;
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages/${msgId}/react`)
.set('Cookie', authCookie(user.id))
.send({ emoji: '👍' });
expect(res.status).toBe(200);
expect(res.body.reactions).toBeDefined();
});
it('COLLAB-018 — POST react without emoji returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msg = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Test' });
const msgId = msg.body.message.id;
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages/${msgId}/react`)
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(400);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Long text validation
// ─────────────────────────────────────────────────────────────────────────────
describe('Collab validation', () => {
it('COLLAB-018 — message text exceeding 5000 chars is rejected', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'A'.repeat(5001) });
expect(res.status).toBe(400);
});
});

View File

@@ -0,0 +1,235 @@
/**
* Day Notes integration tests.
* Covers NOTE-001 to NOTE-006.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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, createDay, addTripMember } 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();
});
// ─────────────────────────────────────────────────────────────────────────────
// Create day note
// ─────────────────────────────────────────────────────────────────────────────
describe('Create day note', () => {
it('NOTE-001 — POST creates a day note', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
const res = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Remember to book tickets', time: '09:00' });
expect(res.status).toBe(201);
expect(res.body.note.text).toBe('Remember to book tickets');
expect(res.body.note.time).toBe('09:00');
});
it('NOTE-001 — POST without text returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
.set('Cookie', authCookie(user.id))
.send({ time: '10:00' });
expect(res.status).toBe(400);
});
it('NOTE-002 — text exceeding 500 characters is rejected', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
.set('Cookie', authCookie(user.id))
.send({ text: 'A'.repeat(501) });
expect(res.status).toBe(400);
});
it('NOTE-001 — POST on non-existent day returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/days/99999/notes`)
.set('Cookie', authCookie(user.id))
.send({ text: 'This should fail' });
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// List day notes
// ─────────────────────────────────────────────────────────────────────────────
describe('List day notes', () => {
it('NOTE-003 — GET returns notes for a day', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Note A' });
await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Note B' });
const res = await request(app)
.get(`/api/trips/${trip.id}/days/${day.id}/notes`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.notes).toHaveLength(2);
});
it('NOTE-006 — non-member cannot list notes', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/days/${day.id}/notes`)
.set('Cookie', authCookie(other.id));
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Update day note
// ─────────────────────────────────────────────────────────────────────────────
describe('Update day note', () => {
it('NOTE-004 — PUT updates a note', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Old text' });
const noteId = create.body.note.id;
const res = await request(app)
.put(`/api/trips/${trip.id}/days/${day.id}/notes/${noteId}`)
.set('Cookie', authCookie(user.id))
.send({ text: 'New text', icon: '🎯' });
expect(res.status).toBe(200);
expect(res.body.note.text).toBe('New text');
expect(res.body.note.icon).toBe('🎯');
});
it('NOTE-004 — PUT on non-existent note returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/days/${day.id}/notes/99999`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Updated' });
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Delete day note
// ─────────────────────────────────────────────────────────────────────────────
describe('Delete day note', () => {
it('NOTE-005 — DELETE removes note', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
.set('Cookie', authCookie(user.id))
.send({ text: 'To delete' });
const noteId = create.body.note.id;
const del = await request(app)
.delete(`/api/trips/${trip.id}/days/${day.id}/notes/${noteId}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
const list = await request(app)
.get(`/api/trips/${trip.id}/days/${day.id}/notes`)
.set('Cookie', authCookie(user.id));
expect(list.body.notes).toHaveLength(0);
});
it('NOTE-005 — DELETE non-existent note returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const res = await request(app)
.delete(`/api/trips/${trip.id}/days/${day.id}/notes/99999`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
});

View File

@@ -0,0 +1,465 @@
/**
* Days & Accommodations API integration tests.
* Covers DAY-001 through DAY-006 and ACCOM-001 through ACCOM-003.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
// ─────────────────────────────────────────────────────────────────────────────
// In-memory DB — schema applied in beforeAll after mocks register
// ─────────────────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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, createDay, createPlace, addTripMember } 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(); });
// ─────────────────────────────────────────────────────────────────────────────
// List days (DAY-001, DAY-002)
// ─────────────────────────────────────────────────────────────────────────────
describe('List days', () => {
it('DAY-001 — GET /api/trips/:tripId/days returns days for a trip the user can access', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip', start_date: '2026-06-01', end_date: '2026-06-03' });
const res = await request(app)
.get(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.days).toBeDefined();
expect(Array.isArray(res.body.days)).toBe(true);
expect(res.body.days).toHaveLength(3);
});
it('DAY-001 — Member can list days for a shared trip', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip', start_date: '2026-07-01', end_date: '2026-07-02' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(200);
expect(res.body.days).toHaveLength(2);
});
it('DAY-002 — Non-member cannot list days (404)', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
const res = await request(app)
.get(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(stranger.id));
expect(res.status).toBe(404);
});
it('DAY-002 — Unauthenticated request returns 401', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const res = await request(app).get(`/api/trips/${trip.id}/days`);
expect(res.status).toBe(401);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Create day (DAY-006)
// ─────────────────────────────────────────────────────────────────────────────
describe('Create day', () => {
it('DAY-006 — POST /api/trips/:tripId/days creates a standalone day with no date', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Open Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(user.id))
.send({ notes: 'A free day' });
expect(res.status).toBe(201);
expect(res.body.day).toBeDefined();
expect(res.body.day.trip_id).toBe(trip.id);
expect(res.body.day.date).toBeNull();
expect(res.body.day.notes).toBe('A free day');
});
it('DAY-006 — POST /api/trips/:tripId/days creates a day with a date', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Dated Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(user.id))
.send({ date: '2026-08-15' });
expect(res.status).toBe(201);
expect(res.body.day.date).toBe('2026-08-15');
});
it('DAY-006 — Non-member cannot create a day (404)', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Private' });
const res = await request(app)
.post(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(stranger.id))
.send({ notes: 'Infiltration' });
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Update day (DAY-003, DAY-004)
// ─────────────────────────────────────────────────────────────────────────────
describe('Update day', () => {
it('DAY-003 — PUT /api/trips/:tripId/days/:dayId updates the day title', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'My Trip' });
const day = createDay(testDb, trip.id, { title: 'Old Title' });
const res = await request(app)
.put(`/api/trips/${trip.id}/days/${day.id}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'New Title' });
expect(res.status).toBe(200);
expect(res.body.day).toBeDefined();
expect(res.body.day.title).toBe('New Title');
});
it('DAY-004 — PUT /api/trips/:tripId/days/:dayId updates the day notes', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'My Trip' });
const day = createDay(testDb, trip.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/days/${day.id}`)
.set('Cookie', authCookie(user.id))
.send({ notes: 'Visit the Louvre' });
expect(res.status).toBe(200);
expect(res.body.day.notes).toBe('Visit the Louvre');
});
it('DAY-003 — PUT returns 404 for a day that does not belong to the trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'My Trip' });
createDay(testDb, trip.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/days/999999`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Ghost' });
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('DAY-003 — Non-member cannot update a day (404)', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Private' });
const day = createDay(testDb, trip.id, { title: 'Original' });
const res = await request(app)
.put(`/api/trips/${trip.id}/days/${day.id}`)
.set('Cookie', authCookie(stranger.id))
.send({ title: 'Hacked' });
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Reorder days (DAY-005)
// ─────────────────────────────────────────────────────────────────────────────
describe('Reorder days', () => {
it('DAY-005 — Reorder: GET days returns them in day_number order', async () => {
const { user } = createUser(testDb);
// Create trip with 3 days auto-generated
const trip = createTrip(testDb, user.id, {
title: 'Trip',
start_date: '2026-09-01',
end_date: '2026-09-03',
});
const res = await request(app)
.get(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.days).toHaveLength(3);
// Days should be ordered by day_number ascending (the service sorts by day_number ASC)
expect(res.body.days[0].date).toBe('2026-09-01');
expect(res.body.days[2].date).toBe('2026-09-03');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Delete day
// ─────────────────────────────────────────────────────────────────────────────
describe('Delete day', () => {
it('DELETE /api/trips/:tripId/days/:dayId removes the day', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const day = createDay(testDb, trip.id);
const res = await request(app)
.delete(`/api/trips/${trip.id}/days/${day.id}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const deleted = testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id);
expect(deleted).toBeUndefined();
});
it('DELETE /api/trips/:tripId/days/:dayId returns 404 for unknown day', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const res = await request(app)
.delete(`/api/trips/${trip.id}/days/999999`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Accommodations (ACCOM-001, ACCOM-002, ACCOM-003)
// ─────────────────────────────────────────────────────────────────────────────
describe('Accommodations', () => {
it('ACCOM-001 — POST /api/trips/:tripId/accommodations creates an accommodation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
const day1 = createDay(testDb, trip.id, { date: '2026-10-01' });
const day2 = createDay(testDb, trip.id, { date: '2026-10-03' });
const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' });
const res = 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,
check_in: '15:00',
check_out: '11:00',
confirmation: 'ABC123',
notes: 'Breakfast included',
});
expect(res.status).toBe(201);
expect(res.body.accommodation).toBeDefined();
expect(res.body.accommodation.place_id).toBe(place.id);
expect(res.body.accommodation.start_day_id).toBe(day1.id);
expect(res.body.accommodation.end_day_id).toBe(day2.id);
expect(res.body.accommodation.confirmation).toBe('ABC123');
});
it('ACCOM-001 — POST missing required fields returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/accommodations`)
.set('Cookie', authCookie(user.id))
.send({ notes: 'no ids' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/required/i);
});
it('ACCOM-001 — POST with invalid place_id returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const day = createDay(testDb, trip.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/accommodations`)
.set('Cookie', authCookie(user.id))
.send({ place_id: 999999, start_day_id: day.id, end_day_id: day.id });
expect(res.status).toBe(404);
});
it('ACCOM-002 — GET /api/trips/:tripId/accommodations returns accommodations for the trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
const day1 = createDay(testDb, trip.id, { date: '2026-11-01' });
const day2 = createDay(testDb, trip.id, { date: '2026-11-03' });
const place = createPlace(testDb, trip.id, { name: 'Boutique Inn' });
// Seed accommodation directly
testDb.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?)'
).run(trip.id, place.id, day1.id, day2.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/accommodations`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.accommodations).toBeDefined();
expect(Array.isArray(res.body.accommodations)).toBe(true);
expect(res.body.accommodations).toHaveLength(1);
expect(res.body.accommodations[0].place_name).toBe('Boutique Inn');
});
it('ACCOM-002 — Non-member cannot get accommodations (404)', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
const res = await request(app)
.get(`/api/trips/${trip.id}/accommodations`)
.set('Cookie', authCookie(stranger.id));
expect(res.status).toBe(404);
});
it('ACCOM-003 — DELETE /api/trips/:tripId/accommodations/:id removes accommodation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
const day1 = createDay(testDb, trip.id, { date: '2026-12-01' });
const day2 = createDay(testDb, trip.id, { date: '2026-12-03' });
const place = createPlace(testDb, trip.id, { name: 'Budget Hostel' });
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 });
expect(createRes.status).toBe(201);
const accommodationId = createRes.body.accommodation.id;
const deleteRes = await request(app)
.delete(`/api/trips/${trip.id}/accommodations/${accommodationId}`)
.set('Cookie', authCookie(user.id));
expect(deleteRes.status).toBe(200);
expect(deleteRes.body.success).toBe(true);
// Verify removed from DB
const row = testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(accommodationId);
expect(row).toBeUndefined();
});
it('ACCOM-003 — DELETE non-existent accommodation returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const res = await request(app)
.delete(`/api/trips/${trip.id}/accommodations/999999`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('ACCOM-001 — Creating accommodation also creates a linked reservation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
const day1 = createDay(testDb, trip.id, { date: '2026-10-10' });
const day2 = createDay(testDb, trip.id, { date: '2026-10-12' });
const place = createPlace(testDb, trip.id, { name: 'Luxury Resort' });
const res = 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, confirmation: 'CONF-XYZ' });
expect(res.status).toBe(201);
// Linked reservation should exist
const reservation = testDb.prepare(
'SELECT * FROM reservations WHERE accommodation_id = ?'
).get(res.body.accommodation.id) as any;
expect(reservation).toBeDefined();
expect(reservation.type).toBe('hotel');
expect(reservation.confirmation_number).toBe('CONF-XYZ');
});
it('ACCOM-003 — Deleting accommodation also removes the linked reservation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
const day1 = createDay(testDb, trip.id, { date: '2026-10-15' });
const day2 = createDay(testDb, trip.id, { date: '2026-10-17' });
const place = createPlace(testDb, trip.id, { name: 'Mountain Lodge' });
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 });
const accommodationId = createRes.body.accommodation.id;
const reservationBefore = testDb.prepare(
'SELECT id FROM reservations WHERE accommodation_id = ?'
).get(accommodationId) as any;
expect(reservationBefore).toBeDefined();
const deleteRes = await request(app)
.delete(`/api/trips/${trip.id}/accommodations/${accommodationId}`)
.set('Cookie', authCookie(user.id));
expect(deleteRes.status).toBe(200);
const reservationAfter = testDb.prepare(
'SELECT id FROM reservations WHERE id = ?'
).get(reservationBefore.id);
expect(reservationAfter).toBeUndefined();
});
});

View File

@@ -0,0 +1,382 @@
/**
* Trip Files integration tests.
* Covers FILE-001 to FILE-021.
*
* Notes:
* - Tests use fixture files from tests/fixtures/
* - File uploads create real files in uploads/files/ — tests clean up after themselves where possible
* - FILE-009 (ephemeral token download) is covered via the /api/auth/resource-token endpoint
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import path from 'path';
import fs from 'fs';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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, createReservation, addTripMember } from '../helpers/factories';
import { authCookie, generateToken } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
// Ensure uploads/files dir exists
const uploadsDir = path.join(__dirname, '../../uploads/files');
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
// Seed allowed_file_types to include common types (wildcard)
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
// Re-seed allowed_file_types after reset
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
});
afterAll(() => {
testDb.close();
});
// Helper to upload a file and return the file object
async function uploadFile(tripId: number, userId: number, fixturePath = FIXTURE_PDF) {
const res = await request(app)
.post(`/api/trips/${tripId}/files`)
.set('Cookie', authCookie(userId))
.attach('file', fixturePath);
return res;
}
// ─────────────────────────────────────────────────────────────────────────────
// Upload file
// ─────────────────────────────────────────────────────────────────────────────
describe('Upload file', () => {
it('FILE-001 — POST uploads a file and returns file metadata', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await uploadFile(trip.id, user.id, FIXTURE_PDF);
expect(res.status).toBe(201);
expect(res.body.file).toBeDefined();
expect(res.body.file.id).toBeDefined();
expect(res.body.file.filename).toBeDefined();
});
it('FILE-002 — uploading a blocked extension (.svg) is rejected', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Create a temp .svg file
const svgPath = path.join(uploadsDir, 'test_blocked.svg');
fs.writeFileSync(svgPath, '<svg></svg>');
try {
const res = await request(app)
.post(`/api/trips/${trip.id}/files`)
.set('Cookie', authCookie(user.id))
.attach('file', svgPath);
expect(res.status).toBe(400);
} finally {
if (fs.existsSync(svgPath)) fs.unlinkSync(svgPath);
}
});
it('FILE-021 — non-member cannot upload file', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/files`)
.set('Cookie', authCookie(other.id))
.attach('file', FIXTURE_PDF);
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// List files
// ─────────────────────────────────────────────────────────────────────────────
describe('List files', () => {
it('FILE-006 — GET returns all non-trashed files', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await uploadFile(trip.id, user.id, FIXTURE_PDF);
await uploadFile(trip.id, user.id, FIXTURE_IMG);
const res = await request(app)
.get(`/api/trips/${trip.id}/files`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.files.length).toBeGreaterThanOrEqual(2);
});
it('FILE-007 — GET ?trash=true returns only trashed files', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
const fileId = upload.body.file.id;
// Soft-delete it
await request(app)
.delete(`/api/trips/${trip.id}/files/${fileId}`)
.set('Cookie', authCookie(user.id));
const trash = await request(app)
.get(`/api/trips/${trip.id}/files?trash=true`)
.set('Cookie', authCookie(user.id));
expect(trash.status).toBe(200);
const trashIds = (trash.body.files as any[]).map((f: any) => f.id);
expect(trashIds).toContain(fileId);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Star / unstar
// ─────────────────────────────────────────────────────────────────────────────
describe('Star/unstar file', () => {
it('FILE-011 — PATCH /:id/star toggles starred status', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
const fileId = upload.body.file.id;
const res = await request(app)
.patch(`/api/trips/${trip.id}/files/${fileId}/star`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.file.starred).toBe(1);
// Toggle back
const res2 = await request(app)
.patch(`/api/trips/${trip.id}/files/${fileId}/star`)
.set('Cookie', authCookie(user.id));
expect(res2.body.file.starred).toBe(0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Soft delete / restore / permanent delete
// ─────────────────────────────────────────────────────────────────────────────
describe('Soft delete, restore, permanent delete', () => {
it('FILE-012 — DELETE moves file to trash', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
const fileId = upload.body.file.id;
const del = await request(app)
.delete(`/api/trips/${trip.id}/files/${fileId}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
// Should not appear in normal list
const list = await request(app)
.get(`/api/trips/${trip.id}/files`)
.set('Cookie', authCookie(user.id));
const ids = (list.body.files as any[]).map((f: any) => f.id);
expect(ids).not.toContain(fileId);
});
it('FILE-013 — POST /:id/restore restores from trash', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
const fileId = upload.body.file.id;
await request(app)
.delete(`/api/trips/${trip.id}/files/${fileId}`)
.set('Cookie', authCookie(user.id));
const restore = await request(app)
.post(`/api/trips/${trip.id}/files/${fileId}/restore`)
.set('Cookie', authCookie(user.id));
expect(restore.status).toBe(200);
expect(restore.body.file.id).toBe(fileId);
});
it('FILE-014 — DELETE /:id/permanent permanently deletes from trash', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
const fileId = upload.body.file.id;
await request(app)
.delete(`/api/trips/${trip.id}/files/${fileId}`)
.set('Cookie', authCookie(user.id));
const perm = await request(app)
.delete(`/api/trips/${trip.id}/files/${fileId}/permanent`)
.set('Cookie', authCookie(user.id));
expect(perm.status).toBe(200);
expect(perm.body.success).toBe(true);
});
it('FILE-015 — DELETE /:id/permanent on non-trashed file returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
const fileId = upload.body.file.id;
// Not trashed — should 404
const res = await request(app)
.delete(`/api/trips/${trip.id}/files/${fileId}/permanent`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
it('FILE-016 — DELETE /trash/empty empties all trash', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const f1 = await uploadFile(trip.id, user.id, FIXTURE_PDF);
const f2 = await uploadFile(trip.id, user.id, FIXTURE_IMG);
await request(app).delete(`/api/trips/${trip.id}/files/${f1.body.file.id}`).set('Cookie', authCookie(user.id));
await request(app).delete(`/api/trips/${trip.id}/files/${f2.body.file.id}`).set('Cookie', authCookie(user.id));
const empty = await request(app)
.delete(`/api/trips/${trip.id}/files/trash/empty`)
.set('Cookie', authCookie(user.id));
expect(empty.status).toBe(200);
const trash = await request(app)
.get(`/api/trips/${trip.id}/files?trash=true`)
.set('Cookie', authCookie(user.id));
expect(trash.body.files).toHaveLength(0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Update file metadata
// ─────────────────────────────────────────────────────────────────────────────
describe('Update file metadata', () => {
it('FILE-017 — PUT updates description', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
const fileId = upload.body.file.id;
const res = await request(app)
.put(`/api/trips/${trip.id}/files/${fileId}`)
.set('Cookie', authCookie(user.id))
.send({ description: 'My important document' });
expect(res.status).toBe(200);
expect(res.body.file.description).toBe('My important document');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// File links
// ─────────────────────────────────────────────────────────────────────────────
describe('File links', () => {
it('FILE-018/019/020 — link file to reservation, list links, unlink', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const resv = createReservation(testDb, trip.id, { title: 'My Flight', type: 'flight' });
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
const fileId = upload.body.file.id;
// Link (POST /:id/link)
const link = await request(app)
.post(`/api/trips/${trip.id}/files/${fileId}/link`)
.set('Cookie', authCookie(user.id))
.send({ reservation_id: resv.id });
expect(link.status).toBe(200);
expect(link.body.success).toBe(true);
// List links (GET /:id/links)
const links = await request(app)
.get(`/api/trips/${trip.id}/files/${fileId}/links`)
.set('Cookie', authCookie(user.id));
expect(links.status).toBe(200);
expect(links.body.links.some((l: any) => l.reservation_id === resv.id)).toBe(true);
// Unlink (DELETE /:id/link/:linkId — use the link id from the list)
const linkId = links.body.links.find((l: any) => l.reservation_id === resv.id)?.id;
expect(linkId).toBeDefined();
const unlink = await request(app)
.delete(`/api/trips/${trip.id}/files/${fileId}/link/${linkId}`)
.set('Cookie', authCookie(user.id));
expect(unlink.status).toBe(200);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Download
// ─────────────────────────────────────────────────────────────────────────────
describe('File download', () => {
it('FILE-010 — GET /:id/download without auth returns 401', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
const fileId = upload.body.file.id;
const res = await request(app)
.get(`/api/trips/${trip.id}/files/${fileId}/download`);
expect(res.status).toBe(401);
});
it('FILE-008 — GET /:id/download with Bearer JWT downloads or 404s (no physical file in tests)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
const fileId = upload.body.file.id;
// authenticateDownload accepts a signed JWT as Bearer token
const token = generateToken(user.id);
const dl = await request(app)
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
.set('Authorization', `Bearer ${token}`);
// multer stores the file to disk during uploadFile — physical file exists
expect(dl.status).toBe(200);
});
});

View File

@@ -0,0 +1,122 @@
/**
* Basic smoke test to validate the integration test DB mock pattern.
* Tests MISC-001 — Health check endpoint.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
// ─────────────────────────────────────────────────────────────────────────────
// Step 1: Create a bare in-memory DB instance via vi.hoisted() so it exists
// before the mock factory below runs. Schema setup happens in beforeAll
// (after mocks are registered, so config is mocked when migrations run).
// ─────────────────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: number) => {
const place: any = db.prepare(`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 2: Register mocks BEFORE app is imported (these are hoisted by Vitest)
// ─────────────────────────────────────────────────────────────────────────────
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
// ─────────────────────────────────────────────────────────────────────────────
// Step 3: Import app AFTER mocks (Vitest hoisting ensures mocks are ready first)
// ─────────────────────────────────────────────────────────────────────────────
import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
const app: Application = createApp();
// Schema setup runs here — config is mocked so migrations work correctly
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
describe('Health check', () => {
it('MISC-001 — GET /api/health returns 200 with status ok', async () => {
const res = await request(app).get('/api/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
});
});
describe('Basic auth', () => {
it('AUTH-014 — GET /api/auth/me without session returns 401', async () => {
const res = await request(app).get('/api/auth/me');
expect(res.status).toBe(401);
expect(res.body.code).toBe('AUTH_REQUIRED');
});
it('AUTH-001 — POST /api/auth/login with valid credentials returns 200 + cookie', async () => {
const { user, password } = createUser(testDb);
const res = await request(app)
.post('/api/auth/login')
.send({ email: user.email, password });
expect(res.status).toBe(200);
expect(res.body.user).toMatchObject({ id: user.id, email: user.email });
expect(res.headers['set-cookie']).toBeDefined();
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
? res.headers['set-cookie']
: [res.headers['set-cookie']];
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
});
it('AUTH-014 — authenticated GET /api/auth/me returns user object', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/auth/me')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.user.id).toBe(user.id);
expect(res.body.user.email).toBe(user.email);
});
});

View File

@@ -0,0 +1,147 @@
/**
* Immich integration tests.
* Covers IMMICH-001 to IMMICH-015 (settings, SSRF protection, connection test).
*
* External Immich API calls are not made — tests focus on settings persistence
* and input validation.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
// Mock SSRF guard: block loopback and private IPs, allow external hostnames without DNS.
vi.mock('../../src/utils/ssrfGuard', async () => {
const actual = await vi.importActual<typeof import('../../src/utils/ssrfGuard')>('../../src/utils/ssrfGuard');
return {
...actual,
checkSsrf: vi.fn().mockImplementation(async (rawUrl: string) => {
try {
const url = new URL(rawUrl);
const h = url.hostname;
if (h === '127.0.0.1' || h === '::1' || h === 'localhost') {
return { allowed: false, isPrivate: true, error: 'Requests to loopback addresses are not allowed' };
}
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h)) {
return { allowed: false, isPrivate: true, error: 'Requests to private network addresses are not allowed' };
}
return { allowed: true, isPrivate: false, resolvedIp: '93.184.216.34' };
} catch {
return { allowed: false, isPrivate: false, error: 'Invalid URL' };
}
}),
};
});
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('Immich settings', () => {
it('IMMICH-001 — GET /api/immich/settings returns current settings', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/integrations/immich/settings')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
// Settings may be empty initially
expect(res.body).toBeDefined();
});
it('IMMICH-001 — PUT /api/immich/settings saves Immich URL and API key', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put('/api/integrations/immich/settings')
.set('Cookie', authCookie(user.id))
.send({ immich_url: 'https://immich.example.com', immich_api_key: 'test-api-key' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('IMMICH-002 — PUT /api/immich/settings with private IP is blocked by SSRF guard', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put('/api/integrations/immich/settings')
.set('Cookie', authCookie(user.id))
.send({ immich_url: 'http://192.168.1.100', immich_api_key: 'test-key' });
expect(res.status).toBe(400);
});
it('IMMICH-002 — PUT /api/immich/settings with loopback is blocked', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put('/api/integrations/immich/settings')
.set('Cookie', authCookie(user.id))
.send({ immich_url: 'http://127.0.0.1:2283', immich_api_key: 'test-key' });
expect(res.status).toBe(400);
});
});
describe('Immich authentication', () => {
it('GET /api/immich/settings without auth returns 401', async () => {
const res = await request(app).get('/api/integrations/immich/settings');
expect(res.status).toBe(401);
});
it('PUT /api/immich/settings without auth returns 401', async () => {
const res = await request(app)
.put('/api/integrations/immich/settings')
.send({ url: 'https://example.com', api_key: 'key' });
expect(res.status).toBe(401);
});
});

View File

@@ -0,0 +1,135 @@
/**
* Maps integration tests.
* Covers MAPS-001 to MAPS-008.
*
* External API calls (Nominatim, Google Places, Wikipedia) are tested at the
* input validation level. Full integration tests would require live external APIs.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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('Maps authentication', () => {
it('POST /maps/search without auth returns 401', async () => {
const res = await request(app)
.post('/api/maps/search')
.send({ query: 'Paris' });
expect(res.status).toBe(401);
});
it('GET /maps/reverse without auth returns 401', async () => {
const res = await request(app)
.get('/api/maps/reverse?lat=48.8566&lng=2.3522');
expect(res.status).toBe(401);
});
});
describe('Maps validation', () => {
it('MAPS-001 — POST /maps/search without query returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/maps/search')
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(400);
});
it('MAPS-006 — GET /maps/reverse without lat/lng returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/maps/reverse')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
});
it('MAPS-007 — POST /maps/resolve-url without url returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/maps/resolve-url')
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(400);
});
});
describe('Maps SSRF protection', () => {
it('MAPS-007 — POST /maps/resolve-url with internal IP is blocked', async () => {
const { user } = createUser(testDb);
// SSRF: should be blocked by ssrfGuard
const res = await request(app)
.post('/api/maps/resolve-url')
.set('Cookie', authCookie(user.id))
.send({ url: 'http://192.168.1.1/admin' });
expect(res.status).toBe(400);
});
it('MAPS-007 — POST /maps/resolve-url with loopback IP is blocked', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/maps/resolve-url')
.set('Cookie', authCookie(user.id))
.send({ url: 'http://127.0.0.1/secret' });
expect(res.status).toBe(400);
});
});

View File

@@ -0,0 +1,132 @@
/**
* MCP integration tests.
* Covers MCP-001 to MCP-013.
*
* The MCP endpoint uses JWT auth and server-sent events / streaming HTTP.
* Tests focus on authentication and basic rejection behavior.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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 { generateToken } 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('MCP authentication', () => {
// MCP handler checks if the 'mcp' addon is enabled first (403 if not),
// then checks auth (401). In test DB the addon may be disabled.
it('MCP-001 — POST /mcp without auth returns 403 (addon disabled before auth check)', async () => {
const res = await request(app)
.post('/mcp')
.send({ jsonrpc: '2.0', method: 'initialize', id: 1 });
// MCP handler checks addon enabled before verifying auth; addon is disabled in test DB
expect(res.status).toBe(403);
});
it('MCP-001 — GET /mcp without auth returns 403 (addon disabled)', async () => {
const res = await request(app).get('/mcp');
expect(res.status).toBe(403);
});
it('MCP-001 — DELETE /mcp without auth returns 403 (addon disabled)', async () => {
const res = await request(app)
.delete('/mcp')
.set('Mcp-Session-Id', 'fake-session-id');
expect(res.status).toBe(403);
});
});
describe('MCP session init', () => {
it('MCP-002 — POST /mcp with valid JWT passes auth check (may fail if addon disabled)', async () => {
const { user } = createUser(testDb);
const token = generateToken(user.id);
// Enable MCP addon in test DB
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
const res = await request(app)
.post('/mcp')
.set('Authorization', `Bearer ${token}`)
.set('Accept', 'application/json, text/event-stream')
.send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
// Valid JWT + enabled addon → auth passes; SDK returns 200 with session headers
expect(res.status).toBe(200);
});
it('MCP-003 — DELETE /mcp with unknown session returns 404', async () => {
const { user } = createUser(testDb);
const token = generateToken(user.id);
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
const res = await request(app)
.delete('/mcp')
.set('Authorization', `Bearer ${token}`)
.set('Mcp-Session-Id', 'nonexistent-session-id');
expect(res.status).toBe(404);
});
it('MCP-004 — POST /mcp with invalid JWT returns 401 (when addon enabled)', async () => {
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
const res = await request(app)
.post('/mcp')
.set('Authorization', 'Bearer invalid.jwt.token')
.send({ jsonrpc: '2.0', method: 'initialize', id: 1 });
expect(res.status).toBe(401);
});
});

View File

@@ -0,0 +1,142 @@
/**
* Miscellaneous integration tests.
* Covers MISC-001, 002, 004, 007, 008, 013, 015.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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('Health check', () => {
it('MISC-001 — GET /api/health returns 200 with status ok', async () => {
const res = await request(app).get('/api/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
});
});
describe('Addons list', () => {
it('MISC-002 — GET /api/addons returns enabled addons', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/addons')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.addons)).toBe(true);
// Should only return enabled addons
const enabled = (res.body.addons as any[]).filter((a: any) => !a.enabled);
expect(enabled.length).toBe(0);
});
});
describe('Photo endpoint auth', () => {
it('MISC-007 — GET /uploads/files without auth is blocked (401)', async () => {
// /uploads/files is blocked without auth; /uploads/avatars and /uploads/covers are public static
const res = await request(app).get('/uploads/files/nonexistent.txt');
expect(res.status).toBe(401);
});
});
describe('Force HTTPS redirect', () => {
it('MISC-004 — FORCE_HTTPS redirect sends 301 for HTTP requests', async () => {
// createApp() reads FORCE_HTTPS at call time, so we need a fresh app instance
process.env.FORCE_HTTPS = 'true';
let httpsApp: Express;
try {
httpsApp = createApp();
} finally {
delete process.env.FORCE_HTTPS;
}
const res = await request(httpsApp)
.get('/api/health')
.set('X-Forwarded-Proto', 'http');
expect(res.status).toBe(301);
});
it('MISC-004 — no redirect when FORCE_HTTPS is not set', async () => {
delete process.env.FORCE_HTTPS;
const res = await request(app)
.get('/api/health')
.set('X-Forwarded-Proto', 'http');
expect(res.status).toBe(200);
});
});
describe('Categories endpoint', () => {
it('MISC-013/PLACE-015 — GET /api/categories returns seeded categories', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/categories')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.categories)).toBe(true);
expect(res.body.categories.length).toBeGreaterThan(0);
});
});
describe('App config', () => {
it('MISC-015 — GET /api/auth/app-config returns configuration', async () => {
const res = await request(app).get('/api/auth/app-config');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('allow_registration');
expect(res.body).toHaveProperty('oidc_configured');
});
});

View File

@@ -0,0 +1,177 @@
/**
* Notifications integration tests.
* Covers NOTIF-001 to NOTIF-014.
*
* External SMTP / webhook calls are not made — tests focus on preferences,
* in-app notification CRUD, and authentication.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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('Notification preferences', () => {
it('NOTIF-001 — GET /api/notifications/preferences returns defaults', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/notifications/preferences')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('preferences');
});
it('NOTIF-001 — PUT /api/notifications/preferences updates settings', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put('/api/notifications/preferences')
.set('Cookie', authCookie(user.id))
.send({ notify_trip_invite: true, notify_booking_change: false });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('preferences');
});
it('NOTIF — GET preferences without auth returns 401', async () => {
const res = await request(app).get('/api/notifications/preferences');
expect(res.status).toBe(401);
});
});
describe('In-app notifications', () => {
it('NOTIF-008 — GET /api/notifications/in-app returns notifications array', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/notifications/in-app')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.notifications)).toBe(true);
});
it('NOTIF-008 — GET /api/notifications/in-app/unread-count returns count', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/notifications/in-app/unread-count')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('count');
expect(typeof res.body.count).toBe('number');
});
it('NOTIF-009 — PUT /api/notifications/in-app/read-all marks all read', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put('/api/notifications/in-app/read-all')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('NOTIF-010 — DELETE /api/notifications/in-app/all deletes all notifications', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.delete('/api/notifications/in-app/all')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('NOTIF-011 — PUT /api/notifications/in-app/:id/read on non-existent returns 404', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put('/api/notifications/in-app/99999/read')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
it('NOTIF-012 — DELETE /api/notifications/in-app/:id on non-existent returns 404', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.delete('/api/notifications/in-app/99999')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
});
describe('Notification test endpoints', () => {
it('NOTIF-005 — POST /api/notifications/test-smtp requires admin', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/notifications/test-smtp')
.set('Cookie', authCookie(user.id));
// Non-admin gets 403
expect(res.status).toBe(403);
});
it('NOTIF-006 — POST /api/notifications/test-webhook requires admin', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/notifications/test-webhook')
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(403);
});
});

View File

@@ -0,0 +1,362 @@
/**
* Packing List integration tests.
* Covers PACK-001 to PACK-014.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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, createPackingItem, addTripMember } 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();
});
// ─────────────────────────────────────────────────────────────────────────────
// Create packing item
// ─────────────────────────────────────────────────────────────────────────────
describe('Create packing item', () => {
it('PACK-001 — POST creates a packing item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Passport', category: 'Documents' });
expect(res.status).toBe(201);
expect(res.body.item.name).toBe('Passport');
expect(res.body.item.category).toBe('Documents');
expect(res.body.item.checked).toBe(0);
});
it('PACK-001 — POST without name returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing`)
.set('Cookie', authCookie(user.id))
.send({ category: 'Clothing' });
expect(res.status).toBe(400);
});
it('PACK-014 — non-member cannot create packing item', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing`)
.set('Cookie', authCookie(other.id))
.send({ name: 'Sunscreen' });
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// List packing items
// ─────────────────────────────────────────────────────────────────────────────
describe('List packing items', () => {
it('PACK-002 — GET /api/trips/:tripId/packing returns all items', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
createPackingItem(testDb, trip.id, { name: 'Shirt', category: 'Clothing' });
const res = await request(app)
.get(`/api/trips/${trip.id}/packing`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.items).toHaveLength(2);
});
it('PACK-002 — member can list packing items', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
createPackingItem(testDb, trip.id, { name: 'Jacket' });
const res = await request(app)
.get(`/api/trips/${trip.id}/packing`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(200);
expect(res.body.items).toHaveLength(1);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Update packing item
// ─────────────────────────────────────────────────────────────────────────────
describe('Update packing item', () => {
it('PACK-003 — PUT updates packing item (toggle checked)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createPackingItem(testDb, trip.id, { name: 'Camera' });
const res = await request(app)
.put(`/api/trips/${trip.id}/packing/${item.id}`)
.set('Cookie', authCookie(user.id))
.send({ checked: true });
expect(res.status).toBe(200);
expect(res.body.item.checked).toBe(1);
});
it('PACK-003 — PUT returns 404 for non-existent item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/packing/99999`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Updated' });
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Delete packing item
// ─────────────────────────────────────────────────────────────────────────────
describe('Delete packing item', () => {
it('PACK-004 — DELETE removes packing item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createPackingItem(testDb, trip.id, { name: 'Sunglasses' });
const del = await request(app)
.delete(`/api/trips/${trip.id}/packing/${item.id}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
const list = await request(app)
.get(`/api/trips/${trip.id}/packing`)
.set('Cookie', authCookie(user.id));
expect(list.body.items).toHaveLength(0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bulk import
// ─────────────────────────────────────────────────────────────────────────────
describe('Bulk import packing items', () => {
it('PACK-005 — POST /import creates multiple items at once', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/import`)
.set('Cookie', authCookie(user.id))
.send({
items: [
{ name: 'Toothbrush', category: 'Toiletries' },
{ name: 'Shampoo', category: 'Toiletries' },
{ name: 'Socks', category: 'Clothing' },
],
});
expect(res.status).toBe(201);
expect(res.body.items).toHaveLength(3);
expect(res.body.count).toBe(3);
});
it('PACK-005 — POST /import with empty array returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/import`)
.set('Cookie', authCookie(user.id))
.send({ items: [] });
expect(res.status).toBe(400);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Reorder
// ─────────────────────────────────────────────────────────────────────────────
describe('Reorder packing items', () => {
it('PACK-006 — PUT /reorder reorders items', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const i1 = createPackingItem(testDb, trip.id, { name: 'Item A' });
const i2 = createPackingItem(testDb, trip.id, { name: 'Item B' });
const res = await request(app)
.put(`/api/trips/${trip.id}/packing/reorder`)
.set('Cookie', authCookie(user.id))
.send({ orderedIds: [i2.id, i1.id] });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bags
// ─────────────────────────────────────────────────────────────────────────────
describe('Bags', () => {
it('PACK-008 — POST /bags creates a bag', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/bags`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Carry-on', color: '#3b82f6' });
expect(res.status).toBe(201);
expect(res.body.bag.name).toBe('Carry-on');
});
it('PACK-008 — POST /bags without name returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/bags`)
.set('Cookie', authCookie(user.id))
.send({ color: '#ff0000' });
expect(res.status).toBe(400);
});
it('PACK-011 — GET /bags returns bags list', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Create a bag
await request(app)
.post(`/api/trips/${trip.id}/packing/bags`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Main Bag' });
const res = await request(app)
.get(`/api/trips/${trip.id}/packing/bags`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.bags).toHaveLength(1);
});
it('PACK-009 — PUT /bags/:bagId updates bag', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const createRes = await request(app)
.post(`/api/trips/${trip.id}/packing/bags`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Old Name' });
const bagId = createRes.body.bag.id;
const res = await request(app)
.put(`/api/trips/${trip.id}/packing/bags/${bagId}`)
.set('Cookie', authCookie(user.id))
.send({ name: 'New Name' });
expect(res.status).toBe(200);
expect(res.body.bag.name).toBe('New Name');
});
it('PACK-010 — DELETE /bags/:bagId removes bag', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const createRes = await request(app)
.post(`/api/trips/${trip.id}/packing/bags`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Temp Bag' });
const bagId = createRes.body.bag.id;
const del = await request(app)
.delete(`/api/trips/${trip.id}/packing/bags/${bagId}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Category assignees
// ─────────────────────────────────────────────────────────────────────────────
describe('Category assignees', () => {
it('PACK-012 — PUT /category-assignees/:category sets assignees', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/packing/category-assignees/Clothing`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: [user.id, member.id] });
expect(res.status).toBe(200);
expect(res.body.assignees).toBeDefined();
});
it('PACK-013 — GET /category-assignees returns all category assignments', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Set an assignee first
await request(app)
.put(`/api/trips/${trip.id}/packing/category-assignees/Electronics`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: [user.id] });
const res = await request(app)
.get(`/api/trips/${trip.id}/packing/category-assignees`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.assignees).toBeDefined();
});
});

View File

@@ -0,0 +1,530 @@
/**
* Places API integration tests.
* Covers PLACE-001 through PLACE-019.
*
* Notes:
* - PLACE-008/009: place-to-day assignment is tested in assignments.test.ts
* - PLACE-014: reordering within a day is tested in assignments.test.ts
* - PLACE-019: GPX bulk import tested here using the test fixture
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import path from 'path';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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, createTrip, createPlace, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx');
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
});
afterAll(() => {
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// Create place
// ─────────────────────────────────────────────────────────────────────────────
describe('Create place', () => {
it('PLACE-001 — POST /api/trips/:tripId/places creates place and returns 201', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 });
expect(res.status).toBe(201);
expect(res.body.place.name).toBe('Eiffel Tower');
expect(res.body.place.lat).toBe(48.8584);
expect(res.body.place.trip_id).toBe(trip.id);
});
it('PLACE-001 — POST without name returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(user.id))
.send({ lat: 48.8584, lng: 2.2945 });
expect(res.status).toBe(400);
});
it('PLACE-002 — name exceeding 200 characters is rejected', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(user.id))
.send({ name: 'A'.repeat(201) });
expect(res.status).toBe(400);
});
it('PLACE-007 — non-member cannot create a place', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(other.id))
.send({ name: 'Test Place' });
expect(res.status).toBe(404);
});
it('PLACE-016 — create place with category assigns it correctly', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const cat = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number };
const res = await request(app)
.post(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Louvre', category_id: cat.id });
expect(res.status).toBe(201);
expect(res.body.place.category).toBeDefined();
expect(res.body.place.category.id).toBe(cat.id);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// List places
// ─────────────────────────────────────────────────────────────────────────────
describe('List places', () => {
it('PLACE-003 — GET /api/trips/:tripId/places returns all places', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createPlace(testDb, trip.id, { name: 'Place A' });
createPlace(testDb, trip.id, { name: 'Place B' });
const res = await request(app)
.get(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.places).toHaveLength(2);
});
it('PLACE-003 — member can list places for a shared trip', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
createPlace(testDb, trip.id, { name: 'Shared Place' });
const res = await request(app)
.get(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(200);
expect(res.body.places).toHaveLength(1);
});
it('PLACE-007 — non-member cannot list places', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(other.id));
expect(res.status).toBe(404);
});
it('PLACE-017 — GET /api/trips/:tripId/places?category=X filters by category id', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const cats = testDb.prepare('SELECT id, name FROM categories LIMIT 2').all() as { id: number; name: string }[];
expect(cats.length).toBeGreaterThanOrEqual(2);
createPlace(testDb, trip.id, { name: 'Hotel Alpha', category_id: cats[0].id });
createPlace(testDb, trip.id, { name: 'Hotel Beta', category_id: cats[0].id });
createPlace(testDb, trip.id, { name: 'Restaurant Gamma', category_id: cats[1].id });
// The route filters by category_id, not name
const res = await request(app)
.get(`/api/trips/${trip.id}/places?category=${cats[0].id}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.places).toHaveLength(2);
expect(res.body.places.every((p: any) => p.category?.id === cats[0].id)).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Get single place
// ─────────────────────────────────────────────────────────────────────────────
describe('Get place', () => {
it('PLACE-004 — GET /api/trips/:tripId/places/:id returns place with tags', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Test Place' });
const res = await request(app)
.get(`/api/trips/${trip.id}/places/${place.id}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.place.id).toBe(place.id);
expect(Array.isArray(res.body.place.tags)).toBe(true);
});
it('PLACE-004 — GET non-existent place returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/places/99999`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Update place
// ─────────────────────────────────────────────────────────────────────────────
describe('Update place', () => {
it('PLACE-005 — PUT /api/trips/:tripId/places/:id updates place details', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Old Name' });
const res = await request(app)
.put(`/api/trips/${trip.id}/places/${place.id}`)
.set('Cookie', authCookie(user.id))
.send({ name: 'New Name', description: 'Updated description' });
expect(res.status).toBe(200);
expect(res.body.place.name).toBe('New Name');
expect(res.body.place.description).toBe('Updated description');
});
it('PLACE-005 — PUT returns 404 for non-existent place', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/places/99999`)
.set('Cookie', authCookie(user.id))
.send({ name: 'New Name' });
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Delete place
// ─────────────────────────────────────────────────────────────────────────────
describe('Delete place', () => {
it('PLACE-006 — DELETE /api/trips/:tripId/places/:id removes place', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
const del = await request(app)
.delete(`/api/trips/${trip.id}/places/${place.id}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
const get = await request(app)
.get(`/api/trips/${trip.id}/places/${place.id}`)
.set('Cookie', authCookie(user.id));
expect(get.status).toBe(404);
});
it('PLACE-007 — member with default permissions can delete a place', 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);
const res = await request(app)
.delete(`/api/trips/${trip.id}/places/${place.id}`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(200);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Tags
// ─────────────────────────────────────────────────────────────────────────────
describe('Tags', () => {
it('PLACE-013 — GET /api/tags returns user tags', async () => {
const { user } = createUser(testDb);
// Create a tag in DB
testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('Must-see', user.id);
const res = await request(app)
.get('/api/tags')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.tags).toBeDefined();
const names = (res.body.tags as any[]).map((t: any) => t.name);
expect(names).toContain('Must-see');
});
it('PLACE-010/011 — POST place with tags associates them correctly', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Pre-create a tag
const tagResult = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('Romantic', user.id);
const tagId = tagResult.lastInsertRowid as number;
// The places API accepts `tags` as an array of tag IDs
const res = await request(app)
.post(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Dinner Spot', tags: [tagId] });
expect(res.status).toBe(201);
// Get place with tags
const getRes = await request(app)
.get(`/api/trips/${trip.id}/places/${res.body.place.id}`)
.set('Cookie', authCookie(user.id));
expect(getRes.body.place.tags.some((t: any) => t.id === tagId)).toBe(true);
});
it('PLACE-012 — DELETE /api/tags/:id removes tag', async () => {
const { user } = createUser(testDb);
const tagResult = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('OldTag', user.id);
const tagId = tagResult.lastInsertRowid as number;
const res = await request(app)
.delete(`/api/tags/${tagId}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
const tags = await request(app).get('/api/tags').set('Cookie', authCookie(user.id));
expect((tags.body.tags as any[]).some((t: any) => t.id === tagId)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Update place tags (PLACE-011)
// ─────────────────────────────────────────────────────────────────────────────
describe('Update place tags', () => {
it('PLACE-011 — PUT with tags array replaces existing tags', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tag1Result = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('OldTag', user.id);
const tag2Result = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('NewTag', user.id);
const tag1Id = tag1Result.lastInsertRowid as number;
const tag2Id = tag2Result.lastInsertRowid as number;
// Create place with tag1
const createRes = await request(app)
.post(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Taggable Place', tags: [tag1Id] });
expect(createRes.status).toBe(201);
const placeId = createRes.body.place.id;
// Update with tag2 only — should replace tag1
const updateRes = await request(app)
.put(`/api/trips/${trip.id}/places/${placeId}`)
.set('Cookie', authCookie(user.id))
.send({ tags: [tag2Id] });
expect(updateRes.status).toBe(200);
const tags = updateRes.body.place.tags as any[];
expect(tags.some((t: any) => t.id === tag2Id)).toBe(true);
expect(tags.some((t: any) => t.id === tag1Id)).toBe(false);
});
it('PLACE-011 — PUT with empty tags array removes all tags', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tagResult = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('RemovableTag', user.id);
const tagId = tagResult.lastInsertRowid as number;
const createRes = await request(app)
.post(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Place With Tag', tags: [tagId] });
const placeId = createRes.body.place.id;
const updateRes = await request(app)
.put(`/api/trips/${trip.id}/places/${placeId}`)
.set('Cookie', authCookie(user.id))
.send({ tags: [] });
expect(updateRes.status).toBe(200);
expect(updateRes.body.place.tags).toHaveLength(0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Place notes (PLACE-018)
// ─────────────────────────────────────────────────────────────────────────────
describe('Place notes', () => {
it('PLACE-018 — Create a place with notes', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Noted Place', notes: 'Book in advance!' });
expect(res.status).toBe(201);
expect(res.body.place.notes).toBe('Book in advance!');
});
it('PLACE-018 — Update place notes via PUT', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'My Spot' });
const res = await request(app)
.put(`/api/trips/${trip.id}/places/${place.id}`)
.set('Cookie', authCookie(user.id))
.send({ notes: 'Updated notes here' });
expect(res.status).toBe(200);
expect(res.body.place.notes).toBe('Updated notes here');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Search filter (PLACE-017 search variant)
// ─────────────────────────────────────────────────────────────────────────────
describe('Search places', () => {
it('PLACE-017 — GET ?search= filters places by name', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
createPlace(testDb, trip.id, { name: 'Arc de Triomphe' });
const res = await request(app)
.get(`/api/trips/${trip.id}/places?search=Eiffel`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.places).toHaveLength(1);
expect(res.body.places[0].name).toBe('Eiffel Tower');
});
it('PLACE-017 — GET ?tag= filters by tag id', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tagResult = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('Scenic', user.id);
const tagId = tagResult.lastInsertRowid as number;
// Create place with the tag and one without
const createRes = await request(app)
.post(`/api/trips/${trip.id}/places`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Scenic Place', tags: [tagId] });
expect(createRes.status).toBe(201);
createPlace(testDb, trip.id, { name: 'Plain Place' });
const res = await request(app)
.get(`/api/trips/${trip.id}/places?tag=${tagId}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.places).toHaveLength(1);
expect(res.body.places[0].name).toBe('Scenic Place');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Categories
// ─────────────────────────────────────────────────────────────────────────────
describe('Categories', () => {
it('PLACE-015 — GET /api/categories returns all categories', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/categories')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.categories)).toBe(true);
expect(res.body.categories.length).toBeGreaterThan(0);
expect(res.body.categories[0]).toHaveProperty('name');
expect(res.body.categories[0]).toHaveProperty('color');
expect(res.body.categories[0]).toHaveProperty('icon');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// GPX Import
// ─────────────────────────────────────────────────────────────────────────────
describe('GPX Import', () => {
it('PLACE-019 — POST /import/gpx with valid GPX file creates places', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/places/import/gpx`)
.set('Cookie', authCookie(user.id))
.attach('file', GPX_FIXTURE);
expect(res.status).toBe(201);
expect(res.body.places).toBeDefined();
expect(res.body.count).toBeGreaterThan(0);
});
it('PLACE-019 — POST /import/gpx without file 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/gpx`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
});
});

View File

@@ -0,0 +1,302 @@
/**
* User Profile & Settings integration tests.
* Covers PROFILE-001 to PROFILE-015.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import path from 'path';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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, createTrip } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
const FIXTURE_JPEG = path.join(__dirname, '../fixtures/small-image.jpg');
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
});
afterAll(() => {
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// Profile
// ─────────────────────────────────────────────────────────────────────────────
describe('PROFILE-001 — Get current user profile', () => {
it('returns user object with expected fields', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/auth/me')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.user).toMatchObject({
id: user.id,
email: user.email,
username: user.username,
});
expect(res.body.user.password_hash).toBeUndefined();
expect(res.body.user.mfa_secret).toBeUndefined();
expect(res.body.user).toHaveProperty('mfa_enabled');
expect(res.body.user).toHaveProperty('must_change_password');
});
});
describe('Avatar', () => {
it('PROFILE-002 — upload valid JPEG avatar updates avatar_url', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/auth/avatar')
.set('Cookie', authCookie(user.id))
.attach('avatar', FIXTURE_JPEG);
expect(res.status).toBe(200);
expect(res.body.avatar_url).toBeDefined();
expect(typeof res.body.avatar_url).toBe('string');
});
it('PROFILE-003 — uploading non-image (PDF) is rejected', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/auth/avatar')
.set('Cookie', authCookie(user.id))
.attach('avatar', FIXTURE_PDF);
// multer fileFilter rejects non-image types (cb(null, false) → req.file undefined → 400)
expect(res.status).toBe(400);
});
it('PROFILE-005 — DELETE /api/auth/avatar clears avatar_url', async () => {
const { user } = createUser(testDb);
// Upload first
await request(app)
.post('/api/auth/avatar')
.set('Cookie', authCookie(user.id))
.attach('avatar', FIXTURE_JPEG);
const res = await request(app)
.delete('/api/auth/avatar')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
const me = await request(app)
.get('/api/auth/me')
.set('Cookie', authCookie(user.id));
expect(me.body.user.avatar_url).toBeNull();
});
});
describe('Password change', () => {
it('PROFILE-006 — change password with valid credentials succeeds', async () => {
const { user, password } = createUser(testDb);
const res = await request(app)
.put('/api/auth/me/password')
.set('Cookie', authCookie(user.id))
.send({ current_password: password, new_password: 'NewStr0ng!Pass', confirm_password: 'NewStr0ng!Pass' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('PROFILE-007 — wrong current password returns 401', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put('/api/auth/me/password')
.set('Cookie', authCookie(user.id))
.send({ current_password: 'WrongPass1!', new_password: 'NewStr0ng!Pass', confirm_password: 'NewStr0ng!Pass' });
expect(res.status).toBe(401);
});
it('PROFILE-008 — weak new password is rejected', async () => {
const { user, password } = createUser(testDb);
const res = await request(app)
.put('/api/auth/me/password')
.set('Cookie', authCookie(user.id))
.send({ current_password: password, new_password: 'weak', confirm_password: 'weak' });
expect(res.status).toBe(400);
});
});
describe('Settings', () => {
it('PROFILE-009 — PUT /api/settings with key+value persists and GET returns it', async () => {
const { user } = createUser(testDb);
const put = await request(app)
.put('/api/settings')
.set('Cookie', authCookie(user.id))
.send({ key: 'dark_mode', value: 'dark' });
expect(put.status).toBe(200);
const get = await request(app)
.get('/api/settings')
.set('Cookie', authCookie(user.id));
expect(get.status).toBe(200);
expect(get.body.settings).toHaveProperty('dark_mode', 'dark');
});
it('PROFILE-009 — 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);
});
it('PROFILE-010 — POST /api/settings/bulk saves multiple keys atomically', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/settings/bulk')
.set('Cookie', authCookie(user.id))
.send({ settings: { theme: 'dark', language: 'fr', timezone: 'Europe/Paris' } });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const get = await request(app)
.get('/api/settings')
.set('Cookie', authCookie(user.id));
expect(get.body.settings).toHaveProperty('theme', 'dark');
expect(get.body.settings).toHaveProperty('language', 'fr');
expect(get.body.settings).toHaveProperty('timezone', 'Europe/Paris');
});
});
describe('API Keys', () => {
it('PROFILE-011 — PUT /api/auth/me/api-keys saves keys encrypted at rest', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put('/api/auth/me/api-keys')
.set('Cookie', authCookie(user.id))
.send({ openweather_api_key: 'my-weather-key-123' });
expect(res.status).toBe(200);
// Key in DB should be encrypted (not plaintext)
const row = testDb.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(user.id) as any;
expect(row.openweather_api_key).toMatch(/^enc:v1:/);
});
it('PROFILE-011 — GET /api/auth/me does not return plaintext API keys', async () => {
const { user } = createUser(testDb);
await request(app)
.put('/api/auth/me/api-keys')
.set('Cookie', authCookie(user.id))
.send({ openweather_api_key: 'plaintext-key' });
const me = await request(app)
.get('/api/auth/me')
.set('Cookie', authCookie(user.id));
// The key should be masked or absent, never plaintext
const body = me.body.user;
expect(body.openweather_api_key).not.toBe('plaintext-key');
});
});
describe('Account deletion', () => {
it('PROFILE-013 — DELETE /api/auth/me removes account, subsequent login fails', async () => {
const { user, password } = createUser(testDb);
const del = await request(app)
.delete('/api/auth/me')
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
// Should not be able to log in
const login = await request(app)
.post('/api/auth/login')
.send({ email: user.email, password });
expect(login.status).toBe(401);
});
it('PROFILE-013 — admin cannot delete their own account', async () => {
const { user: admin } = createAdmin(testDb);
// Admins are protected from self-deletion
const res = await request(app)
.delete('/api/auth/me')
.set('Cookie', authCookie(admin.id));
// deleteAccount returns 400 when the user is the last admin
expect(res.status).toBe(400);
});
});
describe('Travel stats', () => {
it('PROFILE-014 — GET /api/auth/travel-stats returns stats object', async () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, {
title: 'France Trip',
start_date: '2024-06-01',
end_date: '2024-06-05',
});
const res = await request(app)
.get('/api/auth/travel-stats')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('totalTrips');
expect(res.body.totalTrips).toBeGreaterThanOrEqual(1);
});
});
describe('Demo mode protections', () => {
it('PROFILE-015 — demo user cannot upload avatar (demoUploadBlock)', async () => {
// demoUploadBlock checks for email === 'demo@nomad.app'
testDb.prepare(
"INSERT INTO users (username, email, password_hash, role) VALUES ('demo', 'demo@nomad.app', 'x', 'user')"
).run();
const demoUser = testDb.prepare('SELECT id FROM users WHERE email = ?').get('demo@nomad.app') as { id: number };
process.env.DEMO_MODE = 'true';
try {
const res = await request(app)
.post('/api/auth/avatar')
.set('Cookie', authCookie(demoUser.id))
.attach('avatar', FIXTURE_JPEG);
expect(res.status).toBe(403);
} finally {
delete process.env.DEMO_MODE;
}
});
});

View File

@@ -0,0 +1,243 @@
/**
* Reservations integration tests.
* Covers RESV-001 to RESV-007.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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, createDay, createReservation, addTripMember } 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();
});
// ─────────────────────────────────────────────────────────────────────────────
// Create reservation
// ─────────────────────────────────────────────────────────────────────────────
describe('Create reservation', () => {
it('RESV-001 — POST /api/trips/:tripId/reservations creates a reservation', 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: 'Hotel Check-in', type: 'hotel' });
expect(res.status).toBe(201);
expect(res.body.reservation.title).toBe('Hotel Check-in');
expect(res.body.reservation.type).toBe('hotel');
});
it('RESV-001 — POST without title returns 400', 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({ type: 'hotel' });
expect(res.status).toBe(400);
});
it('RESV-001 — non-member cannot create reservation', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(other.id))
.send({ title: 'Hotel', type: 'hotel' });
expect(res.status).toBe(404);
});
it('RESV-002 — POST with create_accommodation creates an accommodation record', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
const res = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Grand Hotel', type: 'hotel', day_id: day.id, create_accommodation: true });
expect(res.status).toBe(201);
expect(res.body.reservation).toBeDefined();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// List reservations
// ─────────────────────────────────────────────────────────────────────────────
describe('List reservations', () => {
it('RESV-003 — GET /api/trips/:tripId/reservations returns all reservations', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createReservation(testDb, trip.id, { title: 'Flight Out', type: 'flight' });
createReservation(testDb, trip.id, { title: 'Hotel Stay', type: 'hotel' });
const res = await request(app)
.get(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.reservations).toHaveLength(2);
});
it('RESV-003 — returns empty array when no reservations exist', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.reservations).toHaveLength(0);
});
it('RESV-007 — non-member cannot list reservations', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(other.id));
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Update reservation
// ─────────────────────────────────────────────────────────────────────────────
describe('Update reservation', () => {
it('RESV-004 — PUT updates reservation fields', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const resv = createReservation(testDb, trip.id, { title: 'Old Flight', type: 'flight' });
const res = await request(app)
.put(`/api/trips/${trip.id}/reservations/${resv.id}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'New Flight', confirmation_number: 'ABC123' });
expect(res.status).toBe(200);
expect(res.body.reservation.title).toBe('New Flight');
expect(res.body.reservation.confirmation_number).toBe('ABC123');
});
it('RESV-004 — PUT on non-existent reservation returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/reservations/99999`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Updated' });
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Delete reservation
// ─────────────────────────────────────────────────────────────────────────────
describe('Delete reservation', () => {
it('RESV-005 — DELETE removes reservation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const resv = createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' });
const del = await request(app)
.delete(`/api/trips/${trip.id}/reservations/${resv.id}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
const list = await request(app)
.get(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id));
expect(list.body.reservations).toHaveLength(0);
});
it('RESV-005 — DELETE non-existent reservation returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.delete(`/api/trips/${trip.id}/reservations/99999`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Batch update positions
// ─────────────────────────────────────────────────────────────────────────────
describe('Batch update positions', () => {
it('RESV-006 — PUT /positions updates reservation sort order', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r1 = createReservation(testDb, trip.id, { title: 'First', type: 'flight' });
const r2 = createReservation(testDb, trip.id, { title: 'Second', type: 'hotel' });
const res = await request(app)
.put(`/api/trips/${trip.id}/reservations/positions`)
.set('Cookie', authCookie(user.id))
.send({ positions: [{ id: r2.id, position: 0 }, { id: r1.id, position: 1 }] });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});

View File

@@ -0,0 +1,173 @@
/**
* Security integration tests.
* Covers SEC-001 to SEC-015.
*
* Notes:
* - SSRF tests (SEC-001 to SEC-004) are unit-level tests on ssrfGuard — see tests/unit/utils/ssrfGuard.test.ts
* - SEC-015 (MFA backup codes) is covered in auth.test.ts
* - These tests focus on HTTP-level security: headers, auth, injection protection, etc.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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, generateToken } 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('Authentication security', () => {
it('SEC-007 — JWT in Authorization Bearer header authenticates user', async () => {
const { user } = createUser(testDb);
const token = generateToken(user.id);
// The file download endpoint accepts bearer auth
// Other endpoints use cookie auth — but /api/auth/me works with cookie auth
// Test that a forged/invalid JWT is rejected
const res = await request(app)
.get('/api/auth/me')
.set('Authorization', 'Bearer invalid.token.here');
// Should return 401 (auth fails)
expect(res.status).toBe(401);
});
it('unauthenticated request to protected endpoint returns 401', async () => {
const res = await request(app).get('/api/trips');
expect(res.status).toBe(401);
});
it('expired/invalid JWT cookie returns 401', async () => {
const res = await request(app)
.get('/api/trips')
.set('Cookie', 'trek_session=invalid.jwt.token');
expect(res.status).toBe(401);
});
});
describe('Security headers', () => {
it('SEC-011 — Helmet sets X-Content-Type-Options header', async () => {
const res = await request(app).get('/api/health');
expect(res.headers['x-content-type-options']).toBe('nosniff');
});
it('SEC-011 — Helmet sets X-Frame-Options header', async () => {
const res = await request(app).get('/api/health');
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
});
});
describe('API key encryption', () => {
it('SEC-008 — encrypted API keys are stored with enc:v1: prefix', async () => {
const { user } = createUser(testDb);
await request(app)
.put('/api/auth/me/api-keys')
.set('Cookie', authCookie(user.id))
.send({ openweather_api_key: 'test-api-key-12345' });
const row = testDb.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(user.id) as any;
expect(row.openweather_api_key).toMatch(/^enc:v1:/);
});
it('SEC-008 — GET /api/auth/me does not return plaintext API key', async () => {
const { user } = createUser(testDb);
await request(app)
.put('/api/auth/me/api-keys')
.set('Cookie', authCookie(user.id))
.send({ openweather_api_key: 'secret-key' });
const me = await request(app)
.get('/api/auth/me')
.set('Cookie', authCookie(user.id));
expect(me.body.user.openweather_api_key).not.toBe('secret-key');
});
});
describe('MFA secret protection', () => {
it('SEC-009 — GET /api/auth/me does not expose mfa_secret', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/auth/me')
.set('Cookie', authCookie(user.id));
expect(res.body.user.mfa_secret).toBeUndefined();
expect(res.body.user.password_hash).toBeUndefined();
});
});
describe('Request body size limit', () => {
it('SEC-013 — oversized JSON body is rejected', async () => {
// Send a large body (2MB+) to exceed the default limit
const bigData = { data: 'x'.repeat(2 * 1024 * 1024) };
const res = await request(app)
.post('/api/auth/login')
.send(bigData);
// body-parser rejects oversized payloads with 413
expect(res.status).toBe(413);
});
});
describe('File download path traversal', () => {
it('SEC-005 — path traversal in file download is blocked', async () => {
const { user } = createUser(testDb);
const trip = { id: 1 };
const res = await request(app)
.get(`/api/trips/${trip.id}/files/1/download`)
.set('Authorization', `Bearer ${generateToken(user.id)}`);
// Trip 1 does not exist after resetTestDb → 404 before any file path is evaluated
expect(res.status).toBe(404);
});
});

View File

@@ -0,0 +1,207 @@
/**
* Share link integration tests.
* Covers SHARE-001 to SHARE-009.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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';
const app: Application = createApp();
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
});
afterAll(() => {
testDb.close();
});
describe('Share link CRUD', () => {
it('SHARE-001 — POST creates share link with default permissions', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(201);
expect(res.body.token).toBeDefined();
expect(typeof res.body.token).toBe('string');
});
it('SHARE-002 — POST creates share link with custom permissions', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id))
.send({ share_budget: false, share_packing: true });
expect(res.status).toBe(201);
expect(res.body.token).toBeDefined();
});
it('SHARE-003 — POST again updates share link permissions', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const first = await request(app)
.post(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id))
.send({ share_budget: true });
const second = await request(app)
.post(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id))
.send({ share_budget: false });
// Same token (update, not create)
expect(second.body.token).toBe(first.body.token);
});
it('SHARE-004 — GET returns share link status', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await request(app)
.post(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id))
.send({});
const res = await request(app)
.get(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
});
it('SHARE-004 — GET returns null token when no share link exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.token).toBeNull();
});
it('SHARE-005 — DELETE removes share link', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await request(app)
.post(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id))
.send({});
const del = await request(app)
.delete(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
const status = await request(app)
.get(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id));
expect(status.body.token).toBeNull();
});
});
describe('Shared trip access', () => {
it('SHARE-006 — GET /shared/:token returns trip data with all sections', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Adventure' });
const create = await request(app)
.post(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id))
.send({ share_budget: true, share_packing: true });
const token = create.body.token;
const res = await request(app).get(`/api/shared/${token}`);
expect(res.status).toBe(200);
expect(res.body.trip).toBeDefined();
expect(res.body.trip.title).toBe('Paris Adventure');
});
it('SHARE-007 — GET /shared/:token hides budget when share_budget=false', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id))
.send({ share_budget: false });
const token = create.body.token;
const res = await request(app).get(`/api/shared/${token}`);
expect(res.status).toBe(200);
// Budget should be an empty array when share_budget is false
expect(Array.isArray(res.body.budget)).toBe(true);
expect(res.body.budget).toHaveLength(0);
});
it('SHARE-008 — GET /shared/:invalid-token returns 404', async () => {
const res = await request(app).get('/api/shared/invalid-token-xyz');
expect(res.status).toBe(404);
});
it('SHARE-009 — non-member cannot create share link', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(other.id))
.send({});
expect(res.status).toBe(404);
});
});

View File

@@ -0,0 +1,679 @@
/**
* Trips API integration tests.
* Covers TRIP-001 through TRIP-022.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
// ─────────────────────────────────────────────────────────────────────────────
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
// ─────────────────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: number) => {
const place: any = db.prepare(`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
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, createTrip, addTripMember, createPlace, createReservation } 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(); });
// ─────────────────────────────────────────────────────────────────────────────
// Create trip (TRIP-001, TRIP-002, TRIP-003)
// ─────────────────────────────────────────────────────────────────────────────
describe('Create trip', () => {
it('TRIP-001 — POST /api/trips with start_date/end_date returns 201 and auto-generates days', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/trips')
.set('Cookie', authCookie(user.id))
.send({ title: 'Paris Adventure', start_date: '2026-06-01', end_date: '2026-06-05' });
expect(res.status).toBe(201);
expect(res.body.trip).toBeDefined();
expect(res.body.trip.title).toBe('Paris Adventure');
// Verify days were generated (5 days: Jun 15)
const days = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY date').all(res.body.trip.id) as any[];
expect(days).toHaveLength(5);
expect(days[0].date).toBe('2026-06-01');
expect(days[4].date).toBe('2026-06-05');
});
it('TRIP-002 — POST /api/trips without dates returns 201 and no date-specific days', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/trips')
.set('Cookie', authCookie(user.id))
.send({ title: 'Open-ended Trip' });
expect(res.status).toBe(201);
expect(res.body.trip).toBeDefined();
expect(res.body.trip.start_date).toBeNull();
expect(res.body.trip.end_date).toBeNull();
// Days with explicit dates should not be present
const daysWithDate = testDb.prepare('SELECT * FROM days WHERE trip_id = ? AND date IS NOT NULL').all(res.body.trip.id) as any[];
expect(daysWithDate).toHaveLength(0);
});
it('TRIP-001 — POST /api/trips requires a title, returns 400 without one', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/trips')
.set('Cookie', authCookie(user.id))
.send({ description: 'No title here' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/title/i);
});
it('TRIP-001 — POST /api/trips rejects end_date before start_date with 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/trips')
.set('Cookie', authCookie(user.id))
.send({ title: 'Bad Dates', start_date: '2026-06-10', end_date: '2026-06-05' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/end date/i);
});
it('TRIP-003 — trip_create permission set to admin blocks regular user with 403', async () => {
const { user } = createUser(testDb);
// Restrict trip creation to admins only
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('perm_trip_create', 'admin')").run();
invalidatePermissionsCache();
const res = await request(app)
.post('/api/trips')
.set('Cookie', authCookie(user.id))
.send({ title: 'Forbidden Trip' });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/permission/i);
});
it('TRIP-003 — trip_create permission set to admin allows admin user', async () => {
const { user: admin } = createAdmin(testDb);
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('perm_trip_create', 'admin')").run();
invalidatePermissionsCache();
const res = await request(app)
.post('/api/trips')
.set('Cookie', authCookie(admin.id))
.send({ title: 'Admin Trip' });
expect(res.status).toBe(201);
});
it('TRIP-001 — unauthenticated POST /api/trips returns 401', async () => {
const res = await request(app).post('/api/trips').send({ title: 'No Auth' });
expect(res.status).toBe(401);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// List trips (TRIP-004, TRIP-005)
// ─────────────────────────────────────────────────────────────────────────────
describe('List trips', () => {
it('TRIP-004 — GET /api/trips returns own trips and member trips, not other users trips', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const ownTrip = createTrip(testDb, owner.id, { title: "Owner's Trip" });
const memberTrip = createTrip(testDb, stranger.id, { title: "Stranger's Trip (member)" });
createTrip(testDb, stranger.id, { title: "Stranger's Private Trip" });
// Add member to one of stranger's trips
addTripMember(testDb, memberTrip.id, member.id);
const ownerRes = await request(app)
.get('/api/trips')
.set('Cookie', authCookie(owner.id));
expect(ownerRes.status).toBe(200);
const ownerTripIds = ownerRes.body.trips.map((t: any) => t.id);
expect(ownerTripIds).toContain(ownTrip.id);
expect(ownerTripIds).not.toContain(memberTrip.id);
const memberRes = await request(app)
.get('/api/trips')
.set('Cookie', authCookie(member.id));
expect(memberRes.status).toBe(200);
const memberTripIds = memberRes.body.trips.map((t: any) => t.id);
expect(memberTripIds).toContain(memberTrip.id);
expect(memberTripIds).not.toContain(ownTrip.id);
});
it('TRIP-005 — GET /api/trips excludes archived trips by default', async () => {
const { user } = createUser(testDb);
const activeTrip = createTrip(testDb, user.id, { title: 'Active Trip' });
const archivedTrip = createTrip(testDb, user.id, { title: 'Archived Trip' });
// Archive the second trip directly in the DB
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archivedTrip.id);
const res = await request(app)
.get('/api/trips')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
const tripIds = res.body.trips.map((t: any) => t.id);
expect(tripIds).toContain(activeTrip.id);
expect(tripIds).not.toContain(archivedTrip.id);
});
it('TRIP-005 — GET /api/trips?archived=1 returns only archived trips', async () => {
const { user } = createUser(testDb);
const activeTrip = createTrip(testDb, user.id, { title: 'Active Trip' });
const archivedTrip = createTrip(testDb, user.id, { title: 'Archived Trip' });
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archivedTrip.id);
const res = await request(app)
.get('/api/trips?archived=1')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
const tripIds = res.body.trips.map((t: any) => t.id);
expect(tripIds).toContain(archivedTrip.id);
expect(tripIds).not.toContain(activeTrip.id);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Get trip (TRIP-006, TRIP-007, TRIP-016, TRIP-017)
// ─────────────────────────────────────────────────────────────────────────────
describe('Get trip', () => {
it('TRIP-006 — GET /api/trips/:id for own trip returns 200 with full trip object', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'My Trip', description: 'A lovely trip' });
const res = await request(app)
.get(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.trip).toBeDefined();
expect(res.body.trip.id).toBe(trip.id);
expect(res.body.trip.title).toBe('My Trip');
expect(res.body.trip.is_owner).toBe(1);
});
it('TRIP-007 — GET /api/trips/:id for another users trip returns 404', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: "Owner's Trip" });
const res = await request(app)
.get(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(other.id));
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('TRIP-016 — Non-member cannot access trip → 404', async () => {
const { user: owner } = createUser(testDb);
const { user: nonMember } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
const res = await request(app)
.get(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(nonMember.id));
expect(res.status).toBe(404);
});
it('TRIP-017 — Member can access trip → 200', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.get(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(200);
expect(res.body.trip.id).toBe(trip.id);
expect(res.body.trip.is_owner).toBe(0);
});
it('TRIP-006 — GET /api/trips/:id for non-existent trip returns 404', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/trips/999999')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Update trip (TRIP-008, TRIP-009, TRIP-010)
// ─────────────────────────────────────────────────────────────────────────────
describe('Update trip', () => {
it('TRIP-008 — PUT /api/trips/:id updates title and description for owner → 200', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Original Title' });
const res = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Updated Title', description: 'New description' });
expect(res.status).toBe(200);
expect(res.body.trip.title).toBe('Updated Title');
expect(res.body.trip.description).toBe('New description');
});
it('TRIP-009 — Archive trip (PUT with is_archived:true) removes it from normal list', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'To Archive' });
const archiveRes = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ is_archived: true });
expect(archiveRes.status).toBe(200);
expect(archiveRes.body.trip.is_archived).toBe(1);
// Should not appear in the normal list
const listRes = await request(app)
.get('/api/trips')
.set('Cookie', authCookie(user.id));
const tripIds = listRes.body.trips.map((t: any) => t.id);
expect(tripIds).not.toContain(trip.id);
});
it('TRIP-009 — Unarchive trip reappears in normal list', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Archived Trip' });
// Archive it first
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(trip.id);
// Unarchive via API
const unarchiveRes = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ is_archived: false });
expect(unarchiveRes.status).toBe(200);
expect(unarchiveRes.body.trip.is_archived).toBe(0);
// Should appear in the normal list again
const listRes = await request(app)
.get('/api/trips')
.set('Cookie', authCookie(user.id));
const tripIds = listRes.body.trips.map((t: any) => t.id);
expect(tripIds).toContain(trip.id);
});
it('TRIP-010 — Archive by trip member is denied when trip_archive is set to trip_owner', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Members Trip' });
addTripMember(testDb, trip.id, member.id);
// Restrict archiving to trip_owner only (this is actually the default, but set explicitly)
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_trip_archive', 'trip_owner')").run();
invalidatePermissionsCache();
const res = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(member.id))
.send({ is_archived: true });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/permission/i);
});
it('TRIP-008 — Member cannot edit trip title when trip_edit is set to trip_owner', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Original' });
addTripMember(testDb, trip.id, member.id);
// Default trip_edit is trip_owner — members should be blocked
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_trip_edit', 'trip_owner')").run();
invalidatePermissionsCache();
const res = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(member.id))
.send({ title: 'Hacked Title' });
expect(res.status).toBe(403);
});
it('TRIP-008 — PUT /api/trips/:id returns 404 for non-existent trip', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put('/api/trips/999999')
.set('Cookie', authCookie(user.id))
.send({ title: 'Ghost Update' });
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Delete trip (TRIP-018, TRIP-019, TRIP-022)
// ─────────────────────────────────────────────────────────────────────────────
describe('Delete trip', () => {
it('TRIP-018 — DELETE /api/trips/:id by owner returns 200 and trip is no longer accessible', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'To Delete' });
const deleteRes = await request(app)
.delete(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id));
expect(deleteRes.status).toBe(200);
expect(deleteRes.body.success).toBe(true);
// Trip should no longer be accessible
const getRes = await request(app)
.get(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id));
expect(getRes.status).toBe(404);
});
it('TRIP-019 — Regular user cannot delete another users trip → 403', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: "Owner's Trip" });
const res = await request(app)
.delete(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(other.id));
// getTripOwner finds the trip (it exists); checkPermission fails for non-members → 403
expect(res.status).toBe(403);
// Trip still exists
const tripInDb = testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id);
expect(tripInDb).toBeDefined();
});
it('TRIP-019 — Trip member cannot delete trip → 403', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.delete(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/permission/i);
});
it('TRIP-022 — Trip with places and reservations can be deleted (cascade)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip With Data' });
// Add associated data
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
createReservation(testDb, trip.id, { title: 'Hotel Booking', type: 'hotel' });
const deleteRes = await request(app)
.delete(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id));
expect(deleteRes.status).toBe(200);
expect(deleteRes.body.success).toBe(true);
// Verify cascade: places and reservations should be gone
const places = testDb.prepare('SELECT id FROM places WHERE trip_id = ?').all(trip.id);
expect(places).toHaveLength(0);
const reservations = testDb.prepare('SELECT id FROM reservations WHERE trip_id = ?').all(trip.id);
expect(reservations).toHaveLength(0);
});
it('TRIP-018 — Admin can delete another users trip', async () => {
const { user: admin } = createAdmin(testDb);
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: "User's Trip" });
const res = await request(app)
.delete(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('TRIP-018 — DELETE /api/trips/:id for non-existent trip returns 404', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.delete('/api/trips/999999')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Members (TRIP-013, TRIP-014, TRIP-015)
// ─────────────────────────────────────────────────────────────────────────────
describe('Trip members', () => {
it('TRIP-015 — GET /api/trips/:id/members returns owner and members list', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/members`)
.set('Cookie', authCookie(owner.id));
expect(res.status).toBe(200);
expect(res.body.owner).toBeDefined();
expect(res.body.owner.id).toBe(owner.id);
expect(Array.isArray(res.body.members)).toBe(true);
expect(res.body.members.some((m: any) => m.id === member.id)).toBe(true);
expect(res.body.current_user_id).toBe(owner.id);
});
it('TRIP-013 — POST /api/trips/:id/members adds a member by email → 201', async () => {
const { user: owner } = createUser(testDb);
const { user: invitee } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/members`)
.set('Cookie', authCookie(owner.id))
.send({ identifier: invitee.email });
expect(res.status).toBe(201);
expect(res.body.member).toBeDefined();
expect(res.body.member.email).toBe(invitee.email);
expect(res.body.member.role).toBe('member');
// Verify in DB
const dbEntry = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id);
expect(dbEntry).toBeDefined();
});
it('TRIP-013 — POST /api/trips/:id/members adds a member by username → 201', async () => {
const { user: owner } = createUser(testDb);
const { user: invitee } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/members`)
.set('Cookie', authCookie(owner.id))
.send({ identifier: invitee.username });
expect(res.status).toBe(201);
expect(res.body.member.id).toBe(invitee.id);
});
it('TRIP-013 — Adding a non-existent user returns 404', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/members`)
.set('Cookie', authCookie(owner.id))
.send({ identifier: 'nobody@nowhere.example.com' });
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/user not found/i);
});
it('TRIP-013 — Adding a user who is already a member returns 400', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/members`)
.set('Cookie', authCookie(owner.id))
.send({ identifier: member.email });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/already/i);
});
it('TRIP-014 — DELETE /api/trips/:id/members/:userId removes a member → 200', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.delete(`/api/trips/${trip.id}/members/${member.id}`)
.set('Cookie', authCookie(owner.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Verify removal in DB
const dbEntry = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id);
expect(dbEntry).toBeUndefined();
});
it('TRIP-014 — Member can remove themselves from a trip → 200', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.delete(`/api/trips/${trip.id}/members/${member.id}`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('TRIP-013 — Non-owner member cannot add other members when member_manage is trip_owner', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const { user: invitee } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
addTripMember(testDb, trip.id, member.id);
// Restrict member management to trip_owner (default)
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_member_manage', 'trip_owner')").run();
invalidatePermissionsCache();
const res = await request(app)
.post(`/api/trips/${trip.id}/members`)
.set('Cookie', authCookie(member.id))
.send({ identifier: invitee.email });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/permission/i);
});
it('TRIP-015 — Non-member cannot list trip members → 404', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
const res = await request(app)
.get(`/api/trips/${trip.id}/members`)
.set('Cookie', authCookie(stranger.id));
expect(res.status).toBe(404);
});
});

View File

@@ -0,0 +1,306 @@
/**
* Vacay integration tests.
* Covers VACAY-001 to VACAY-025.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
// Mock external holiday API (node-fetch used by some service paths)
vi.mock('node-fetch', () => ({
default: vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([
{ date: '2025-01-01', name: 'New Year\'s Day', countryCode: 'DE' },
]),
}),
}));
// Mock vacayService.getCountries to avoid real HTTP call to nager.at
vi.mock('../../src/services/vacayService', async () => {
const actual = await vi.importActual<typeof import('../../src/services/vacayService')>('../../src/services/vacayService');
return {
...actual,
getCountries: vi.fn().mockResolvedValue({
data: [{ countryCode: 'DE', name: 'Germany' }, { countryCode: 'FR', name: 'France' }],
}),
};
});
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('Vacay plan', () => {
it('VACAY-001 — GET /api/addons/vacay/plan auto-creates plan on first access', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/addons/vacay/plan')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.plan).toBeDefined();
expect(res.body.plan.owner_id).toBe(user.id);
});
it('VACAY-001 — second GET returns same plan (no duplicate creation)', async () => {
const { user } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
const res = await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.plan).toBeDefined();
});
it('VACAY-002 — PUT /api/addons/vacay/plan updates plan settings', async () => {
const { user } = createUser(testDb);
// Ensure plan exists
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
const res = await request(app)
.put('/api/addons/vacay/plan')
.set('Cookie', authCookie(user.id))
.send({ vacation_days: 30, carry_over_days: 5 });
expect(res.status).toBe(200);
});
});
describe('Vacay years', () => {
it('VACAY-007 — POST /api/addons/vacay/years adds a year to the plan', async () => {
const { user } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
const res = await request(app)
.post('/api/addons/vacay/years')
.set('Cookie', authCookie(user.id))
.send({ year: 2025 });
expect(res.status).toBe(200);
expect(res.body.years).toBeDefined();
});
it('VACAY-025 — GET /api/addons/vacay/years lists years in plan', 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)
.get('/api/addons/vacay/years')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.years)).toBe(true);
expect(res.body.years.length).toBeGreaterThanOrEqual(1);
});
it('VACAY-008 — DELETE /api/addons/vacay/years/:year removes year', 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: 2026 });
const res = await request(app)
.delete('/api/addons/vacay/years/2026')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.years).toBeDefined();
});
it('VACAY-011 — PUT /api/addons/vacay/stats/:year updates allowance', 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)
.put('/api/addons/vacay/stats/2025')
.set('Cookie', authCookie(user.id))
.send({ vacation_days: 28 });
expect(res.status).toBe(200);
});
});
describe('Vacay entries', () => {
it('VACAY-003 — POST /api/addons/vacay/entries/toggle marks a day as vacation', 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/toggle')
.set('Cookie', authCookie(user.id))
.send({ date: '2025-06-16', year: 2025, type: 'vacation' });
expect(res.status).toBe(200);
});
it('VACAY-004 — POST /api/addons/vacay/entries/toggle on weekend is allowed (no server-side weekend blocking)', 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 });
// 2025-06-21 is a Saturday — server does not block weekends; client-side only
const res = await request(app)
.post('/api/addons/vacay/entries/toggle')
.set('Cookie', authCookie(user.id))
.send({ date: '2025-06-21', year: 2025, type: 'vacation' });
expect(res.status).toBe(200);
});
it('VACAY-006 — GET /api/addons/vacay/entries/:year returns vacation entries', 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)
.get('/api/addons/vacay/entries/2025')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.entries)).toBe(true);
});
it('VACAY-009 — GET /api/addons/vacay/stats/:year returns stats for year', 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)
.get('/api/addons/vacay/stats/2025')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('stats');
});
});
describe('Vacay color', () => {
it('VACAY-024 — PUT /api/addons/vacay/color sets user color in plan', async () => {
const { user } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
const res = await request(app)
.put('/api/addons/vacay/color')
.set('Cookie', authCookie(user.id))
.send({ color: '#3b82f6' });
expect(res.status).toBe(200);
});
});
describe('Vacay invite flow', () => {
it('VACAY-022 — cannot invite yourself', async () => {
const { user } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
const res = await request(app)
.post('/api/addons/vacay/invite')
.set('Cookie', authCookie(user.id))
.send({ user_id: user.id });
expect(res.status).toBe(400);
});
it('VACAY-016 — send invite to another user', async () => {
const { user: owner } = createUser(testDb);
const { user: invitee } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
const res = await request(app)
.post('/api/addons/vacay/invite')
.set('Cookie', authCookie(owner.id))
.send({ user_id: invitee.id });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('VACAY-023 — GET /api/addons/vacay/available-users returns users who can be invited', async () => {
const { user } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
const res = await request(app)
.get('/api/addons/vacay/available-users')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.users)).toBe(true);
});
});
describe('Vacay holidays', () => {
it('VACAY-014 — GET /api/addons/vacay/holidays/countries returns available countries', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/addons/vacay/holidays/countries')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('VACAY-012 — POST /api/addons/vacay/plan/holiday-calendars adds a holiday calendar', async () => {
const { user } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
const res = await request(app)
.post('/api/addons/vacay/plan/holiday-calendars')
.set('Cookie', authCookie(user.id))
.send({ region: 'DE', label: 'Germany Holidays' });
expect(res.status).toBe(200);
});
});
describe('Vacay dissolve plan', () => {
it('VACAY-020 — POST /api/addons/vacay/dissolve removes user from plan', async () => {
const { user } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
const res = await request(app)
.post('/api/addons/vacay/dissolve')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
});
});

View File

@@ -0,0 +1,157 @@
/**
* Weather integration tests.
* Covers WEATHER-001 to WEATHER-007.
*
* External API calls (Open-Meteo) are mocked via vi.mock.
*/
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: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
// Mock node-fetch / global fetch so no real HTTP calls are made
vi.mock('node-fetch', () => ({
default: vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
current: { temperature_2m: 22, weathercode: 1, windspeed_10m: 10, relativehumidity_2m: 60, precipitation: 0 },
daily: {
time: ['2025-06-01'],
temperature_2m_max: [25],
temperature_2m_min: [18],
weathercode: [1],
precipitation_sum: [0],
windspeed_10m_max: [15],
sunrise: ['2025-06-01T06:00'],
sunset: ['2025-06-01T21:00'],
},
}),
}),
}));
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('Weather validation', () => {
it('WEATHER-001 — GET /weather without lat/lng returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/weather')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
});
it('WEATHER-001 — GET /weather without lng returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/weather?lat=48.8566')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
});
it('WEATHER-005 — GET /weather/detailed without date returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/weather/detailed?lat=48.8566&lng=2.3522')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
});
it('WEATHER-001 — GET /weather without auth returns 401', async () => {
const res = await request(app)
.get('/api/weather?lat=48.8566&lng=2.3522');
expect(res.status).toBe(401);
});
});
describe('Weather with mocked API', () => {
it('WEATHER-001 — GET /weather with lat/lng returns weather data', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/weather?lat=48.8566&lng=2.3522')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('temp');
expect(res.body).toHaveProperty('main');
});
it('WEATHER-002 — GET /weather?date=future returns forecast data', async () => {
const { user } = createUser(testDb);
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 5);
const dateStr = futureDate.toISOString().slice(0, 10);
const res = await request(app)
.get(`/api/weather?lat=48.8566&lng=2.3522&date=${dateStr}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('temp');
expect(res.body).toHaveProperty('type');
});
it('WEATHER-006 — GET /weather accepts lang parameter', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/weather?lat=48.8566&lng=2.3522&lang=en')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('temp');
});
});