import { test, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import request from 'supertest'; import { createApp } from '../src/server/app.js'; let tmpDir; let app; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-checklist-sugg-')); app = createApp({ dbPath: path.join(tmpDir, 'test.db'), sessionSecret: 'test-secret' }); }); afterEach(() => { try { if (app.locals.db) app.locals.db.close(); } catch { /* ignore */ } fs.rmSync(tmpDir, { recursive: true, force: true }); }); async function createAccount() { const agent = request.agent(app); const res = await agent.post('/api/auth/account').send({}); assert.equal(res.status, 201); return { agent, user: res.body.user }; } async function makeTrip(agent, overrides = {}) { return (await agent.post('/api/trips').send({ name: 'S', start_date: '2026-08-01', end_date: '2026-08-10', ...overrides, })).body.trip; } function findSuggestion(suggestions, key) { return suggestions.find((s) => s.key === key); } // --------------------------------------------------------------------------- // Determinism & `added` // --------------------------------------------------------------------------- test('suggestions: two identical GETs return identical arrays', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'flight', title: 'F' }); const first = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`); const second = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`); assert.equal(first.status, 200); assert.deepEqual(first.body.suggestions, second.body.suggestions); assert.deepEqual(first.body.context, second.body.context); }); test('suggestions: `added` is false before the item exists, true after', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); const before = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`); assert.equal(findSuggestion(before.body.suggestions, 'doc-passport').added, false); const add = await agent.post(`/api/trips/${trip.id}/checklist/suggestions`).send({ keys: ['doc-passport'] }); assert.equal(add.status, 201); const after = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`); assert.equal(findSuggestion(after.body.suggestions, 'doc-passport').added, true); }); // --------------------------------------------------------------------------- // Bulk-add // --------------------------------------------------------------------------- test('bulk-add: creates items with suggestion_key set, in current suggestion order', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); const res = await agent.post(`/api/trips/${trip.id}/checklist/suggestions`).send({ keys: ['doc-cards-cash', 'doc-passport'], }); assert.equal(res.status, 201); assert.equal(res.body.created.length, 2); assert.equal(res.body.skipped.length, 0); // doc-passport precedes doc-cards-cash in the rule table -> current suggestion order. assert.deepEqual(res.body.created.map((i) => i.suggestion_key), ['doc-passport', 'doc-cards-cash']); assert.ok(res.body.created.every((i) => i.category === 'Documents')); assert.ok(res.body.created.every((i) => i.user_id === null), 'shared by default'); }); test('bulk-add: already-present keys go to skipped rather than duplicating', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); const first = await agent.post(`/api/trips/${trip.id}/checklist/suggestions`).send({ keys: ['doc-passport'] }); assert.equal(first.status, 201); const second = await agent.post(`/api/trips/${trip.id}/checklist/suggestions`).send({ keys: ['doc-passport', 'toiletries-toothbrush'], }); assert.equal(second.status, 201); assert.deepEqual(second.body.skipped, ['doc-passport']); assert.equal(second.body.created.length, 1); assert.equal(second.body.created[0].suggestion_key, 'toiletries-toothbrush'); const list = (await agent.get(`/api/trips/${trip.id}/checklist`)).body.items; assert.equal(list.filter((i) => i.suggestion_key === 'doc-passport').length, 1, 'not duplicated'); }); test('bulk-add: unknown key -> 400', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); const res = await agent.post(`/api/trips/${trip.id}/checklist/suggestions`).send({ keys: ['not-a-real-key'] }); assert.equal(res.status, 400); assert.deepEqual(res.body, { error: 'unknown suggestion key: not-a-real-key' }); }); test('bulk-add: bad `keys` payloads -> 400', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); const base = `/api/trips/${trip.id}/checklist/suggestions`; assert.equal((await agent.post(base).send({ keys: [] })).status, 400); assert.equal((await agent.post(base).send({ keys: 'doc-passport' })).status, 400); assert.equal((await agent.post(base).send({})).status, 400); assert.equal((await agent.post(base).send({ keys: [123] })).status, 400); const tooMany = Array.from({ length: 61 }, () => 'doc-passport'); assert.equal((await agent.post(base).send({ keys: tooMany })).status, 400); }); // --------------------------------------------------------------------------- // Rule coverage // --------------------------------------------------------------------------- test('rules: clothing qty scales with nights, capped at 10 for a 20-night trip', async () => { const { agent } = await createAccount(); // 21 days inclusive Aug 1 -> Aug 21 = 20 nights. const trip = await makeTrip(agent, { start_date: '2026-08-01', end_date: '2026-08-21' }); const res = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`); assert.equal(res.body.context.nights, 20); assert.equal(findSuggestion(res.body.suggestions, 'clothing-tshirts').qty, 10); assert.equal(findSuggestion(res.body.suggestions, 'clothing-underwear').qty, 10); assert.equal(findSuggestion(res.body.suggestions, 'clothing-socks').qty, 10); }); test('rules: a flight entry triggers liquids and power-bank suggestions', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); const before = (await agent.get(`/api/trips/${trip.id}/checklist/suggestions`)).body.suggestions; assert.equal(findSuggestion(before, 'toiletries-liquids-100ml'), undefined); assert.equal(findSuggestion(before, 'electronics-power-bank'), undefined); await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'flight', title: 'F' }); const after = (await agent.get(`/api/trips/${trip.id}/checklist/suggestions`)).body.suggestions; assert.ok(findSuggestion(after, 'toiletries-liquids-100ml')); assert.ok(findSuggestion(after, 'electronics-power-bank')); }); test('rules: a rental entry triggers driving-licence and IDP suggestions', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); const before = (await agent.get(`/api/trips/${trip.id}/checklist/suggestions`)).body.suggestions; assert.equal(findSuggestion(before, 'doc-driving-licence'), undefined); assert.equal(findSuggestion(before, 'doc-idp'), undefined); await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'rental', title: 'Car' }); const after = (await agent.get(`/api/trips/${trip.id}/checklist/suggestions`)).body.suggestions; assert.ok(findSuggestion(after, 'doc-driving-licence')); assert.ok(findSuggestion(after, 'doc-idp')); }); test('rules: a ferry transport entry triggers motion-sickness suggestion', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); const before = (await agent.get(`/api/trips/${trip.id}/checklist/suggestions`)).body.suggestions; assert.equal(findSuggestion(before, 'health-motion-sickness'), undefined); await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'transport', title: 'Ferry', transport_mode: 'ferry', }); const after = (await agent.get(`/api/trips/${trip.id}/checklist/suggestions`)).body.suggestions; assert.ok(findSuggestion(after, 'health-motion-sickness')); }); test('rules: 3+ stays trigger packing cubes', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); const base = `/api/trips/${trip.id}/entries`; await agent.post(base).send({ date: '2026-08-01', type: 'stay', title: 'A' }); await agent.post(base).send({ date: '2026-08-03', type: 'stay', title: 'B' }); const twoStays = (await agent.get(`/api/trips/${trip.id}/checklist/suggestions`)).body.suggestions; assert.equal(findSuggestion(twoStays, 'extras-packing-cubes'), undefined); await agent.post(base).send({ date: '2026-08-05', type: 'stay', title: 'C' }); const threeStays = (await agent.get(`/api/trips/${trip.id}/checklist/suggestions`)).body.suggestions; assert.ok(findSuggestion(threeStays, 'extras-packing-cubes')); }); // --------------------------------------------------------------------------- // Climate regression: tropical winter destination must not also be tagged cold // --------------------------------------------------------------------------- test('climate: a tropical stop during northern winter is tropical but NOT cold (no warm-layers/hat-gloves)', async () => { const { agent } = await createAccount(); // December trip -> northern winter months. const trip = await makeTrip(agent, { start_date: '2026-12-05', end_date: '2026-12-15' }); await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-12-06', type: 'activity', title: 'Grand Palace', location_name: 'Bangkok, Thailand', lat: 13.7, lng: 100.5, }); const res = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`); assert.deepEqual(res.body.context.climate, ['tropical']); assert.ok(findSuggestion(res.body.suggestions, 'clothing-rain-jacket')); assert.ok(findSuggestion(res.body.suggestions, 'toiletries-sun-cream')); assert.equal(findSuggestion(res.body.suggestions, 'clothing-warm-layers'), undefined); assert.equal(findSuggestion(res.body.suggestions, 'clothing-hat-gloves'), undefined); }); // Real bundled airports.json coordinates, so this exercises the actual // code -> country lookup rather than made-up lat/lng. const FRA = { code: 'FRA', lat: 50.026706, lng: 8.55835 }; // Frankfurt, Germany const BKK = { code: 'BKK', lat: 13.6811, lng: 100.747002 }; // Bangkok, Thailand const MUC = { code: 'MUC', lat: 48.353802, lng: 11.7861 }; // Munich, Germany test('climate/countries: a flight\'s departure airport does not count, only each segment\'s arrival', async () => { const { agent } = await createAccount(); // December trip, flying FROM cold Frankfurt TO tropical Bangkok for a beach // holiday. Only the arrival (Bangkok) should count for climate/countries; // Frankfurt as the mere departure point must not leak in as "cold"/"Germany". const trip = await makeTrip(agent, { start_date: '2026-12-05', end_date: '2026-12-15' }); await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-12-06', type: 'stay', title: 'Bangkok stay', location_name: 'Bangkok, Thailand', }); await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-12-05', type: 'flight', title: 'FRA-BKK', segments: [{ from: FRA, to: BKK }], }); const res = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`); assert.deepEqual(res.body.context.climate, ['tropical']); assert.deepEqual(res.body.context.countries, ['Thailand']); assert.equal(findSuggestion(res.body.suggestions, 'clothing-warm-layers'), undefined); assert.equal(findSuggestion(res.body.suggestions, 'clothing-hat-gloves'), undefined); // Adapter suggestion names Thailand's sockets rather than falling back to // the mixed/universal wording (which would happen if Germany leaked in). const adapter = findSuggestion(res.body.suggestions, 'electronics-adapter'); assert.equal(adapter.text, 'Plug adapter (type A/B/C)'); assert.equal(adapter.reason, 'Thailand uses type A/B/C sockets'); }); test('climate/countries: landing back in a cold place is still counted (arrival of a later segment)', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent, { start_date: '2026-12-05', end_date: '2026-12-15' }); await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-12-06', type: 'stay', title: 'Bangkok stay', location_name: 'Bangkok, Thailand', }); await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-12-05', type: 'flight', title: 'FRA-BKK', segments: [{ from: FRA, to: BKK }], }); // Return leg: Bangkok -> Munich. Munich is only ever an arrival, never a // mere departure, so it must still register as cold/Germany. await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-12-14', type: 'flight', title: 'BKK-MUC', segments: [{ from: BKK, to: MUC }], }); const res = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`); assert.deepEqual(res.body.context.climate.sort(), ['cold', 'tropical']); assert.deepEqual(res.body.context.countries, ['Thailand', 'Germany']); assert.ok(findSuggestion(res.body.suggestions, 'clothing-warm-layers')); assert.ok(findSuggestion(res.body.suggestions, 'clothing-hat-gloves')); }); test('climate: a stop at lat 48 during northern winter IS cold', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent, { start_date: '2026-12-05', end_date: '2026-12-15' }); await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-12-06', type: 'activity', title: 'Christmas market', location_name: 'Munich, Germany', lat: 48.1, lng: 11.6, }); const res = await agent.get(`/api/trips/${trip.id}/checklist/suggestions`); assert.deepEqual(res.body.context.climate, ['cold']); assert.ok(findSuggestion(res.body.suggestions, 'clothing-warm-layers')); assert.ok(findSuggestion(res.body.suggestions, 'clothing-hat-gloves')); assert.equal(findSuggestion(res.body.suggestions, 'clothing-rain-jacket'), undefined); });