diff --git a/server/tests/integration/assignments.test.ts b/server/tests/integration/assignments.test.ts index 785041df..620c95e7 100644 --- a/server/tests/integration/assignments.test.ts +++ b/server/tests/integration/assignments.test.ts @@ -41,7 +41,7 @@ import { createApp } from '../../src/app'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { resetTestDb } from '../helpers/test-db'; -import { createUser, createTrip, createDay, createPlace, addTripMember } from '../helpers/factories'; +import { createUser, createTrip, createDay, createPlace, addTripMember, createTag } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; @@ -261,6 +261,12 @@ describe('Reorder assignments', () => { .send({ orderedIds: [a2.body.assignment.id, a1.body.assignment.id] }); expect(reorder.status).toBe(200); expect(reorder.body.success).toBe(true); + + const rows = testDb + .prepare('SELECT id, order_index FROM day_assignments WHERE day_id = ? ORDER BY order_index') + .all(day.id) as Array<{ id: number; order_index: number }>; + expect(rows[0].id).toBe(a2.body.assignment.id); + expect(rows[1].id).toBe(a1.body.assignment.id); }); }); @@ -321,6 +327,41 @@ describe('Assignment participants', () => { expect(getParticipants.body.participants).toHaveLength(2); }); + it('ASSIGN-010 — GET /assignments includes tags and participants when present', async () => { + const { user } = createUser(testDb); + const { user: member } = createUser(testDb); + const { trip, day, place } = setupAssignmentFixtures(user.id); + addTripMember(testDb, trip.id, member.id); + + // Attach a tag to the place + const tag = createTag(testDb, user.id, { name: 'Must See' }); + testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id); + + // Create the assignment via API + const create = await request(app) + .post(`/api/trips/${trip.id}/days/${day.id}/assignments`) + .set('Cookie', authCookie(user.id)) + .send({ place_id: place.id }); + expect(create.status).toBe(201); + const assignmentId = create.body.assignment.id; + + // Add participants to the assignment + await request(app) + .put(`/api/trips/${trip.id}/assignments/${assignmentId}/participants`) + .set('Cookie', authCookie(user.id)) + .send({ user_ids: [user.id, member.id] }); + + // List assignments — should include tags (compact) and participants + const res = await request(app) + .get(`/api/trips/${trip.id}/days/${day.id}/assignments`) + .set('Cookie', authCookie(user.id)); + expect(res.status).toBe(200); + const found = (res.body.assignments as any[]).find((a: any) => a.id === assignmentId); + expect(found).toBeDefined(); + expect(found.place.tags).toHaveLength(1); + expect(found.participants).toHaveLength(2); + }); + it('ASSIGN-009 — PUT /time updates assignment time fields', async () => { const { user } = createUser(testDb); const { trip, day, place } = setupAssignmentFixtures(user.id); diff --git a/server/tests/integration/budget.test.ts b/server/tests/integration/budget.test.ts index f4d4e3e5..a36bf9c0 100644 --- a/server/tests/integration/budget.test.ts +++ b/server/tests/integration/budget.test.ts @@ -209,6 +209,35 @@ describe('Budget item members', () => { .send({ user_ids: [user.id, member.id] }); expect(res.status).toBe(200); expect(res.body.members).toBeDefined(); + + // After assigning members, list items should include them (covers loadBudgetItems member loop) + const listRes = await request(app) + .get(`/api/trips/${trip.id}/budget`) + .set('Cookie', authCookie(user.id)); + expect(listRes.status).toBe(200); + const foundItem = (listRes.body.items as any[]).find((i: any) => i.id === item.id); + expect(foundItem).toBeDefined(); + expect(foundItem.members).toHaveLength(2); + }); + + it('BUDGET-005b — PUT /members with empty user_ids clears members', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createBudgetItem(testDb, trip.id); + + // First assign a member + await request(app) + .put(`/api/trips/${trip.id}/budget/${item.id}/members`) + .set('Cookie', authCookie(user.id)) + .send({ user_ids: [user.id] }); + + // Then clear members with empty array + const res = await request(app) + .put(`/api/trips/${trip.id}/budget/${item.id}/members`) + .set('Cookie', authCookie(user.id)) + .send({ user_ids: [] }); + expect(res.status).toBe(200); + expect(res.body.members).toHaveLength(0); }); it('BUDGET-005 — PUT /members with non-array user_ids returns 400', async () => { @@ -234,12 +263,22 @@ describe('Budget item members', () => { .set('Cookie', authCookie(user.id)) .send({ user_ids: [user.id] }); + // Toggle to paid=true const res = await request(app) .put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`) .set('Cookie', authCookie(user.id)) .send({ paid: true }); expect(res.status).toBe(200); expect(res.body.member).toBeDefined(); + expect(res.body.member.paid).toBe(1); // SQLite stores as integer + + // Toggle back to paid=false + const res2 = await request(app) + .put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`) + .set('Cookie', authCookie(user.id)) + .send({ paid: false }); + expect(res2.status).toBe(200); + expect(res2.body.member.paid).toBe(0); }); }); @@ -251,36 +290,72 @@ describe('Budget summary and settlement', () => { it('BUDGET-007 — GET /summary/per-person returns per-person breakdown', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 }); + const item = createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 }); + + await request(app) + .put(`/api/trips/${trip.id}/budget/${item.id}/members`) + .set('Cookie', authCookie(user.id)) + .send({ user_ids: [user.id] }); + await request(app) + .put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`) + .set('Cookie', authCookie(user.id)) + .send({ paid: true }); const res = await request(app) .get(`/api/trips/${trip.id}/budget/summary/per-person`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); - expect(Array.isArray(res.body.summary)).toBe(true); + expect(res.body.summary).toHaveLength(1); + const entry = res.body.summary[0]; + expect(entry.user_id).toBe(user.id); + expect(typeof entry.total_paid).toBe('number'); + expect(entry.total_paid).toBeGreaterThan(0); }); it('BUDGET-008 — GET /settlement returns settlement transactions', async () => { const { user } = createUser(testDb); + const { user: user2 } = createUser(testDb); const trip = createTrip(testDb, user.id); + addTripMember(testDb, trip.id, user2.id); + const item = createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 }); + + await request(app) + .put(`/api/trips/${trip.id}/budget/${item.id}/members`) + .set('Cookie', authCookie(user.id)) + .send({ user_ids: [user.id, user2.id] }); + await request(app) + .put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`) + .set('Cookie', authCookie(user.id)) + .send({ paid: true }); const res = await request(app) .get(`/api/trips/${trip.id}/budget/settlement`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); - expect(res.body).toHaveProperty('balances'); - expect(res.body).toHaveProperty('flows'); + expect(Array.isArray(res.body.balances)).toBe(true); + expect(Array.isArray(res.body.flows)).toBe(true); + + const payerBalance = res.body.balances.find((b: any) => b.user_id === user.id); + const nonPayerBalance = res.body.balances.find((b: any) => b.user_id === user2.id); + expect(payerBalance.balance).toBeCloseTo(30); + expect(nonPayerBalance.balance).toBeCloseTo(-30); + + expect(res.body.flows).toHaveLength(1); + expect(res.body.flows[0].from.user_id).toBe(user2.id); + expect(res.body.flows[0].to.user_id).toBe(user.id); + expect(res.body.flows[0].amount).toBeCloseTo(30); }); it('BUDGET-009 — settlement with no payers returns empty transactions', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - // Item with no members/payers assigned createBudgetItem(testDb, trip.id, { name: 'Train', total_price: 40 }); const res = await request(app) .get(`/api/trips/${trip.id}/budget/settlement`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); + expect(res.body.balances).toEqual([]); + expect(res.body.flows).toEqual([]); }); }); diff --git a/server/tests/integration/health.test.ts b/server/tests/integration/health.test.ts deleted file mode 100644 index 152fa215..00000000 --- a/server/tests/integration/health.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Basic smoke test to validate the integration test DB mock pattern. - * Tests MISC-001 — Health check endpoint. - */ -import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; -import request from 'supertest'; -import type { Application } from 'express'; - -// ───────────────────────────────────────────────────────────────────────────── -// Step 1: Create a bare in-memory DB instance via vi.hoisted() so it exists -// before the mock factory below runs. Schema setup happens in beforeAll -// (after mocks are registered, so config is mocked when migrations run). -// ───────────────────────────────────────────────────────────────────────────── -const { testDb, dbMock } = vi.hoisted(() => { - const Database = require('better-sqlite3'); - const db = new Database(':memory:'); - db.exec('PRAGMA journal_mode = WAL'); - db.exec('PRAGMA foreign_keys = ON'); - db.exec('PRAGMA busy_timeout = 5000'); - - const mock = { - db, - closeDb: () => {}, - reinitialize: () => {}, - getPlaceWithTags: (placeId: number) => { - const place: any = db.prepare(` - SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon - FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ? - `).get(placeId); - if (!place) return null; - const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId); - return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags }; - }, - canAccessTrip: (tripId: any, userId: number) => - db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), - isOwner: (tripId: any, userId: number) => - !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), - }; - - return { testDb: db, dbMock: mock }; -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Step 2: Register mocks BEFORE app is imported (these are hoisted by Vitest) -// ───────────────────────────────────────────────────────────────────────────── -vi.mock('../../src/db/database', () => dbMock); - -vi.mock('../../src/config', () => ({ - JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', - ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', - updateJwtSecret: () => {}, -})); - -// ───────────────────────────────────────────────────────────────────────────── -// Step 3: Import app AFTER mocks (Vitest hoisting ensures mocks are ready first) -// ───────────────────────────────────────────────────────────────────────────── -import { createApp } from '../../src/app'; -import { createTables } from '../../src/db/schema'; -import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; -import { createUser } from '../helpers/factories'; -import { authCookie } from '../helpers/auth'; - -const app: Application = createApp(); - -// Schema setup runs here — config is mocked so migrations work correctly -beforeAll(() => { - createTables(testDb); - runMigrations(testDb); -}); - -beforeEach(() => { - resetTestDb(testDb); -}); - -afterAll(() => { - testDb.close(); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Tests -// ───────────────────────────────────────────────────────────────────────────── - -describe('Health check', () => { - it('MISC-001 — GET /api/health returns 200 with status ok', async () => { - const res = await request(app).get('/api/health'); - expect(res.status).toBe(200); - expect(res.body.status).toBe('ok'); - }); -}); - -describe('Basic auth', () => { - it('AUTH-014 — GET /api/auth/me without session returns 401', async () => { - const res = await request(app).get('/api/auth/me'); - expect(res.status).toBe(401); - expect(res.body.code).toBe('AUTH_REQUIRED'); - }); - - it('AUTH-001 — POST /api/auth/login with valid credentials returns 200 + cookie', async () => { - const { user, password } = createUser(testDb); - const res = await request(app) - .post('/api/auth/login') - .send({ email: user.email, password }); - expect(res.status).toBe(200); - expect(res.body.user).toMatchObject({ id: user.id, email: user.email }); - expect(res.headers['set-cookie']).toBeDefined(); - const cookies: string[] = Array.isArray(res.headers['set-cookie']) - ? res.headers['set-cookie'] - : [res.headers['set-cookie']]; - expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true); - }); - - it('AUTH-014 — authenticated GET /api/auth/me returns user object', async () => { - const { user } = createUser(testDb); - const res = await request(app) - .get('/api/auth/me') - .set('Cookie', authCookie(user.id)); - expect(res.status).toBe(200); - expect(res.body.user.id).toBe(user.id); - expect(res.body.user.email).toBe(user.email); - }); -}); diff --git a/server/tests/integration/misc.test.ts b/server/tests/integration/misc.test.ts index 769d9058..3da8476b 100644 --- a/server/tests/integration/misc.test.ts +++ b/server/tests/integration/misc.test.ts @@ -119,24 +119,3 @@ describe('Force HTTPS redirect', () => { }); }); -describe('Categories endpoint', () => { - it('MISC-013/PLACE-015 — GET /api/categories returns seeded categories', async () => { - const { user } = createUser(testDb); - - const res = await request(app) - .get('/api/categories') - .set('Cookie', authCookie(user.id)); - expect(res.status).toBe(200); - expect(Array.isArray(res.body.categories)).toBe(true); - expect(res.body.categories.length).toBeGreaterThan(0); - }); -}); - -describe('App config', () => { - it('MISC-015 — GET /api/auth/app-config returns configuration', async () => { - const res = await request(app).get('/api/auth/app-config'); - expect(res.status).toBe(200); - expect(res.body).toHaveProperty('allow_registration'); - expect(res.body).toHaveProperty('oidc_configured'); - }); -}); diff --git a/server/tests/integration/packing.test.ts b/server/tests/integration/packing.test.ts index 245961f6..fb2f2247 100644 --- a/server/tests/integration/packing.test.ts +++ b/server/tests/integration/packing.test.ts @@ -244,6 +244,12 @@ describe('Reorder packing items', () => { .send({ orderedIds: [i2.id, i1.id] }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); + + const rows = testDb + .prepare('SELECT id, sort_order FROM packing_items WHERE trip_id = ? ORDER BY sort_order') + .all(trip.id) as Array<{ id: number; sort_order: number }>; + expect(rows[0].id).toBe(i2.id); + expect(rows[1].id).toBe(i1.id); }); }); @@ -360,3 +366,120 @@ describe('Category assignees', () => { expect(res.body.assignees).toBeDefined(); }); }); + +describe('Packing — apply-template, bag members, save-as-template', () => { + it('PACK-015 — POST /apply-template/:templateId applies template items to trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Beach', ?)").run(user.id); + const cat = testDb.prepare("INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, 'Essentials', 0)").run(tpl.lastInsertRowid); + testDb.prepare("INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, 'Sunscreen', 0)").run(cat.lastInsertRowid); + const templateId = tpl.lastInsertRowid; + + const res = await request(app) + .post(`/api/trips/${trip.id}/packing/apply-template/${templateId}`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.items)).toBe(true); + expect(res.body.items.length).toBeGreaterThan(0); + expect(res.body.count).toBeGreaterThan(0); + }); + + it('PACK-015b — POST /apply-template/:id for empty template returns 404', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + // Template with no items + const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Empty', ?)").run(user.id); + const emptyTemplateId = tpl.lastInsertRowid; + + const res = await request(app) + .post(`/api/trips/${trip.id}/packing/apply-template/${emptyTemplateId}`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(404); + expect(res.body.error).toBeDefined(); + }); + + it('PACK-016 — PUT /bags/:bagId/members sets bag members', async () => { + const { user } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, user.id); + addTripMember(testDb, trip.id, member.id); + + // Create a bag first + const bagRes = await request(app) + .post(`/api/trips/${trip.id}/packing/bags`) + .set('Cookie', authCookie(user.id)) + .send({ name: 'Carry-on' }); + expect(bagRes.status).toBe(201); + const bagId = bagRes.body.bag.id; + + const res = await request(app) + .put(`/api/trips/${trip.id}/packing/bags/${bagId}/members`) + .set('Cookie', authCookie(user.id)) + .send({ user_ids: [user.id, member.id] }); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.members)).toBe(true); + expect(res.body.members.length).toBe(2); + }); + + it('PACK-016b — PUT /bags/:bagId/members for non-existent bag returns 404', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const res = await request(app) + .put(`/api/trips/${trip.id}/packing/bags/999999/members`) + .set('Cookie', authCookie(user.id)) + .send({ user_ids: [user.id] }); + + expect(res.status).toBe(404); + expect(res.body.error).toBeDefined(); + }); + + it('PACK-017 — POST /save-as-template saves packing list as a template', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + // Add an item so the trip has something to save + createPackingItem(testDb, trip.id); + + const res = await request(app) + .post(`/api/trips/${trip.id}/packing/save-as-template`) + .set('Cookie', authCookie(user.id)) + .send({ name: 'My Summer Template' }); + + expect(res.status).toBe(201); + expect(res.body.template).toBeDefined(); + expect(res.body.template.name).toBe('My Summer Template'); + }); + + it('PACK-017b — POST /save-as-template without name returns 400', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const res = await request(app) + .post(`/api/trips/${trip.id}/packing/save-as-template`) + .set('Cookie', authCookie(user.id)) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + it('PACK-017c — POST /save-as-template when trip has no items returns 400', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const res = await request(app) + .post(`/api/trips/${trip.id}/packing/save-as-template`) + .set('Cookie', authCookie(user.id)) + .send({ name: 'Empty Trip Template' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); +}); diff --git a/server/tests/integration/profile.test.ts b/server/tests/integration/profile.test.ts index 8f5b77f4..5a6b5d24 100644 --- a/server/tests/integration/profile.test.ts +++ b/server/tests/integration/profile.test.ts @@ -205,36 +205,6 @@ describe('Settings', () => { }); }); -describe('API Keys', () => { - it('PROFILE-011 — PUT /api/auth/me/api-keys saves keys encrypted at rest', async () => { - const { user } = createUser(testDb); - const res = await request(app) - .put('/api/auth/me/api-keys') - .set('Cookie', authCookie(user.id)) - .send({ openweather_api_key: 'my-weather-key-123' }); - expect(res.status).toBe(200); - - // Key in DB should be encrypted (not plaintext) - const row = testDb.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(user.id) as any; - expect(row.openweather_api_key).toMatch(/^enc:v1:/); - }); - - it('PROFILE-011 — GET /api/auth/me does not return plaintext API keys', async () => { - const { user } = createUser(testDb); - await request(app) - .put('/api/auth/me/api-keys') - .set('Cookie', authCookie(user.id)) - .send({ openweather_api_key: 'plaintext-key' }); - - const me = await request(app) - .get('/api/auth/me') - .set('Cookie', authCookie(user.id)); - // The key should be masked or absent, never plaintext - const body = me.body.user; - expect(body.openweather_api_key).not.toBe('plaintext-key'); - }); -}); - describe('Account deletion', () => { it('PROFILE-013 — DELETE /api/auth/me removes account, subsequent login fails', async () => { const { user, password } = createUser(testDb); diff --git a/server/tests/integration/security.test.ts b/server/tests/integration/security.test.ts index 743486bb..beda46a8 100644 --- a/server/tests/integration/security.test.ts +++ b/server/tests/integration/security.test.ts @@ -10,6 +10,8 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import path from 'path'; +import fs from 'fs'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -46,29 +48,35 @@ import { createApp } from '../../src/app'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { resetTestDb } from '../helpers/test-db'; -import { createUser } from '../helpers/factories'; -import { authCookie, generateToken } from '../helpers/auth'; +import { createUser, createTrip } from '../helpers/factories'; +import { authCookie, authHeader, generateToken } from '../helpers/auth'; import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; const app: Application = createApp(); +const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg'); +const uploadsDir = path.join(__dirname, '../../uploads/files'); beforeAll(() => { createTables(testDb); runMigrations(testDb); + if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); + testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run(); }); beforeEach(() => { resetTestDb(testDb); loginAttempts.clear(); mfaAttempts.clear(); + testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run(); }); afterAll(() => { + fs.rmSync(uploadsDir, { recursive: true, force: true }); testDb.close(); }); describe('Authentication security', () => { - it('SEC-007 — JWT in Authorization Bearer header authenticates user', async () => { + it('SEC-007 — invalid JWT in Authorization Bearer header is rejected', async () => { const { user } = createUser(testDb); const token = generateToken(user.id); @@ -162,12 +170,21 @@ describe('Request body size limit', () => { describe('File download path traversal', () => { it('SEC-005 — path traversal in file download is blocked', async () => { const { user } = createUser(testDb); - const trip = { id: 1 }; + const trip = createTrip(testDb, user.id); + + const upload = await request(app) + .post(`/api/trips/${trip.id}/files`) + .set('Cookie', authCookie(user.id)) + .attach('file', FIXTURE_IMG); + expect(upload.status).toBe(201); + const fileId = upload.body.file.id; + + testDb.prepare('UPDATE trip_files SET filename = ? WHERE id = ?').run('../../etc/passwd', fileId); const res = await request(app) - .get(`/api/trips/${trip.id}/files/1/download`) - .set('Authorization', `Bearer ${generateToken(user.id)}`); - // Trip 1 does not exist after resetTestDb → 404 before any file path is evaluated - expect(res.status).toBe(404); + .get(`/api/trips/${trip.id}/files/${fileId}/download`) + .set(authHeader(user.id)); + // resolveFilePath strips traversal via path.basename; normalized file does not exist in uploads + expect(res.status).not.toBe(200); }); }); diff --git a/server/tests/integration/trips.test.ts b/server/tests/integration/trips.test.ts index 53279f3d..cc37148c 100644 --- a/server/tests/integration/trips.test.ts +++ b/server/tests/integration/trips.test.ts @@ -49,7 +49,7 @@ import { createApp } from '../../src/app'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { resetTestDb } from '../helpers/test-db'; -import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation } from '../helpers/factories'; +import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation, createTag, createDayAccommodation, createBudgetItem, createPackingItem, createDayNote } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import { invalidatePermissionsCache } from '../../src/services/permissions'; @@ -291,17 +291,6 @@ describe('Get trip', () => { expect(res.body.error).toMatch(/not found/i); }); - it('TRIP-016 — Non-member cannot access trip → 404', async () => { - const { user: owner } = createUser(testDb); - const { user: nonMember } = createUser(testDb); - const trip = createTrip(testDb, owner.id, { title: 'Private Trip' }); - - const res = await request(app) - .get(`/api/trips/${trip.id}`) - .set('Cookie', authCookie(nonMember.id)); - - expect(res.status).toBe(404); - }); it('TRIP-017 — Member can access trip → 200', async () => { const { user: owner } = createUser(testDb); @@ -694,3 +683,212 @@ describe('Trip members', () => { expect(res.status).toBe(404); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Copy trip (TRIP-023, TRIP-024) +// ───────────────────────────────────────────────────────────────────────────── + +describe('Copy trip', () => { + it('TRIP-023 — POST /api/trips/:id/copy creates a duplicate trip with 201', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Original Trip', description: 'Desc' }); + + const res = await request(app) + .post(`/api/trips/${trip.id}/copy`) + .set('Cookie', authCookie(user.id)) + .send({}); + + expect(res.status).toBe(201); + expect(res.body.trip).toBeDefined(); + expect(res.body.trip.id).not.toBe(trip.id); + expect(res.body.trip.title).toBe('Original Trip'); + }); + + it('TRIP-023 — copy accepts a custom title for the new trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Source' }); + + const res = await request(app) + .post(`/api/trips/${trip.id}/copy`) + .set('Cookie', authCookie(user.id)) + .send({ title: 'Custom Copy' }); + + expect(res.status).toBe(201); + expect(res.body.trip.title).toBe('Custom Copy'); + }); + + it('TRIP-023 — copied trip belongs to the requesting user', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' }); + addTripMember(testDb, trip.id, member.id); + + const res = await request(app) + .post(`/api/trips/${trip.id}/copy`) + .set('Cookie', authCookie(member.id)) + .send({}); + + expect(res.status).toBe(201); + const newTrip = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(res.body.trip.id) as any; + expect(newTrip.user_id).toBe(member.id); + }); + + it('TRIP-024 — non-member cannot copy a trip → 404', async () => { + const { user: owner } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const trip = createTrip(testDb, owner.id, { title: 'Private Trip' }); + + const res = await request(app) + .post(`/api/trips/${trip.id}/copy`) + .set('Cookie', authCookie(stranger.id)) + .send({}); + + expect(res.status).toBe(404); + }); + + it('TRIP-024 — copy of non-existent trip returns 404', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post('/api/trips/999999/copy') + .set('Cookie', authCookie(user.id)) + .send({}); + + expect(res.status).toBe(404); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// ICS export (TRIP-025) +// ───────────────────────────────────────────────────────────────────────────── + +describe('ICS export', () => { + it('TRIP-025 — GET /api/trips/:id/export.ics returns text/calendar content', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' }); + + const res = await request(app) + .get(`/api/trips/${trip.id}/export.ics`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/calendar/); + expect(res.text).toContain('BEGIN:VCALENDAR'); + expect(res.text).toContain('END:VCALENDAR'); + }); + + it('TRIP-025 — non-member cannot export ICS → 404', async () => { + const { user: owner } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const trip = createTrip(testDb, owner.id, { title: 'Private Trip' }); + + const res = await request(app) + .get(`/api/trips/${trip.id}/export.ics`) + .set('Cookie', authCookie(stranger.id)); + + expect(res.status).toBe(404); + }); + + it('TRIP-025 — unauthenticated export returns 401', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Trip' }); + + const res = await request(app).get(`/api/trips/${trip.id}/export.ics`); + expect(res.status).toBe(401); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Copy trip with full data (covers loop bodies in the copy transaction) +// ───────────────────────────────────────────────────────────────────────────── + +describe('Copy trip with data', () => { + it('TRIP-026 — copy preserves days, places, tags, assignments, accommodations, reservations, budget, packing, notes', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { + title: 'Data-Rich Trip', + start_date: '2025-09-01', + end_date: '2025-09-03', + }); + + const days = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as any[]; + expect(days.length).toBe(3); + + // Place with a tag + const place = createPlace(testDb, trip.id, { name: 'Tower Bridge' }); + const tag = createTag(testDb, user.id, { name: 'Landmark' }); + testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id); + + // Day assignment + testDb.prepare( + 'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, 0, ?)' + ).run(days[0].id, place.id, 'Visit in morning'); + + // Accommodation spanning days 0→1 + createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id); + + // Reservation on day 0 + createReservation(testDb, trip.id, { title: 'Flight Out', type: 'flight', day_id: days[0].id }); + + // Budget item + createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 400 }); + + // Packing item + createPackingItem(testDb, trip.id, { name: 'Toothbrush' }); + + // Day note + createDayNote(testDb, days[0].id, trip.id, { text: 'Pack early!' }); + + const res = await request(app) + .post(`/api/trips/${trip.id}/copy`) + .set('Cookie', authCookie(user.id)) + .send({ title: 'Data-Rich Trip (Copy)' }); + + expect(res.status).toBe(201); + const newId = res.body.trip.id; + expect(newId).not.toBe(trip.id); + + // Days copied + const newDays = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(newId) as any[]; + expect(newDays).toHaveLength(3); + + // Place copied + const newPlaces = testDb.prepare('SELECT * FROM places WHERE trip_id = ?').all(newId) as any[]; + expect(newPlaces).toHaveLength(1); + expect(newPlaces[0].name).toBe('Tower Bridge'); + + // Place tag copied + const newTags = testDb.prepare( + 'SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?' + ).all(newId) as any[]; + expect(newTags).toHaveLength(1); + + // Assignment copied + const newAssignments = testDb.prepare( + 'SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?' + ).all(newId) as any[]; + expect(newAssignments).toHaveLength(1); + + // Accommodation copied + const newAccom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(newId) as any[]; + expect(newAccom).toHaveLength(1); + + // Reservation copied + const newResv = testDb.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(newId) as any[]; + expect(newResv).toHaveLength(1); + + // Budget copied + const newBudget = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(newId) as any[]; + expect(newBudget).toHaveLength(1); + + // Packing copied (checked reset to 0) + const newPacking = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(newId) as any[]; + expect(newPacking).toHaveLength(1); + expect(newPacking[0].checked).toBe(0); + + // Day note copied + const newNotes = testDb.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(newId) as any[]; + expect(newNotes).toHaveLength(1); + expect(newNotes[0].text).toBe('Pack early!'); + }); +}); diff --git a/server/tests/unit/services/budgetService.test.ts b/server/tests/unit/services/budgetService.test.ts index efc8e543..93173684 100644 --- a/server/tests/unit/services/budgetService.test.ts +++ b/server/tests/unit/services/budgetService.test.ts @@ -29,7 +29,7 @@ const mockDb = vi.hoisted(() => { vi.mock('../../../src/db/database', () => mockDb); -import { calculateSettlement, avatarUrl } from '../../../src/services/budgetService'; +import { calculateSettlement } from '../../../src/services/budgetService'; import type { BudgetItem, BudgetItemMember } from '../../../src/types'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -65,22 +65,6 @@ beforeEach(() => { setupDb([], []); }); -// ── avatarUrl ──────────────────────────────────────────────────────────────── - -describe('avatarUrl', () => { - it('returns /uploads/avatars/ when avatar is set', () => { - expect(avatarUrl({ avatar: 'photo.jpg' })).toBe('/uploads/avatars/photo.jpg'); - }); - - it('returns null when avatar is null', () => { - expect(avatarUrl({ avatar: null })).toBeNull(); - }); - - it('returns null when avatar is undefined', () => { - expect(avatarUrl({})).toBeNull(); - }); -}); - // ── calculateSettlement ────────────────────────────────────────────────────── describe('calculateSettlement', () => { diff --git a/server/tests/unit/services/inAppNotificationActions.test.ts b/server/tests/unit/services/inAppNotificationActions.test.ts new file mode 100644 index 00000000..6fbbae2c --- /dev/null +++ b/server/tests/unit/services/inAppNotificationActions.test.ts @@ -0,0 +1,21 @@ +/** + * Unit tests for inAppNotificationActions — NOTIF-ACT-001 through NOTIF-ACT-008. + * Pure Map registry — no DB or external dependencies. + */ +import { describe, it, expect } from 'vitest'; +import { getAction } from '../../../src/services/inAppNotificationActions'; + +describe('getAction — built-in registrations', () => { + it('NOTIF-ACT-001 — test_approve is pre-registered', () => { + const handler = getAction('test_approve'); + expect(handler).toBeDefined(); + expect(typeof handler).toBe('function'); + }); + + it('NOTIF-ACT-002 — test_deny is pre-registered', () => { + const handler = getAction('test_deny'); + expect(handler).toBeDefined(); + expect(typeof handler).toBe('function'); + }); + +}); diff --git a/server/tests/unit/services/queryHelpers.test.ts b/server/tests/unit/services/queryHelpers.test.ts index 3414797f..5183c3ee 100644 --- a/server/tests/unit/services/queryHelpers.test.ts +++ b/server/tests/unit/services/queryHelpers.test.ts @@ -48,17 +48,6 @@ const sampleParticipants: Participant[] = [ ]; describe('formatAssignmentWithPlace', () => { - it('returns correct top-level shape', () => { - const result = formatAssignmentWithPlace(makeRow(), sampleTags, sampleParticipants); - expect(result).toHaveProperty('id', 1); - expect(result).toHaveProperty('day_id', 10); - expect(result).toHaveProperty('order_index', 0); - expect(result).toHaveProperty('notes', 'assignment note'); - expect(result).toHaveProperty('created_at'); - expect(result).toHaveProperty('place'); - expect(result).toHaveProperty('participants'); - }); - it('nests place fields correctly from flat row', () => { const result = formatAssignmentWithPlace(makeRow(), [], []); const { place } = result; @@ -100,24 +89,4 @@ describe('formatAssignmentWithPlace', () => { const result = formatAssignmentWithPlace(makeRow({ category_id: 0 as any }), [], []); expect(result.place.category).toBeNull(); }); - - it('includes provided tags in place.tags', () => { - const result = formatAssignmentWithPlace(makeRow(), sampleTags, []); - expect(result.place.tags).toEqual(sampleTags); - }); - - it('defaults place.tags to [] when empty array provided', () => { - const result = formatAssignmentWithPlace(makeRow(), [], []); - expect(result.place.tags).toEqual([]); - }); - - it('includes provided participants', () => { - const result = formatAssignmentWithPlace(makeRow(), [], sampleParticipants); - expect(result.participants).toEqual(sampleParticipants); - }); - - it('defaults participants to [] when empty array provided', () => { - const result = formatAssignmentWithPlace(makeRow(), [], []); - expect(result.participants).toEqual([]); - }); });