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-')); 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) { return (await agent.post('/api/trips').send({ name: 'C', start_date: '2026-08-01', end_date: '2026-08-10', })).body.trip; } async function joinTrip(agent, trip) { const res = await agent.post('/api/trips/join').send({ code: trip.join_code }); assert.equal(res.status, 200); } // --------------------------------------------------------------------------- // CRUD // --------------------------------------------------------------------------- test('POST checklist item: defaults applied', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); const res = await agent.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Passport' }); assert.equal(res.status, 201); const item = res.body.item; assert.equal(item.category, 'General'); assert.equal(item.checked, false); assert.equal(item.qty, null); assert.equal(item.checked_by, null); assert.equal(item.personal, false); assert.equal(item.suggestion_key, null); assert.equal(item.sort_order, 0); const second = await agent.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Visa' }); assert.equal(second.body.item.sort_order, 1, 'sort_order appended after the previous max'); }); test('GET checklist: list shape (items + progress), checked is real boolean', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); await agent.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Passport', checked: true }); await agent.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Socks', category: 'Clothing', qty: 5 }); const res = await agent.get(`/api/trips/${trip.id}/checklist`); assert.equal(res.status, 200); assert.equal(res.body.items.length, 2); assert.equal(typeof res.body.items[0].checked, 'boolean'); assert.equal(res.body.progress.total, 2); assert.equal(res.body.progress.checked, 1); assert.ok(Array.isArray(res.body.progress.byCategory)); const socks = res.body.items.find((i) => i.text === 'Socks'); assert.equal(socks.qty, 5); assert.equal(socks.checked_by, null); assert.equal(socks.suggestion_key, null); assert.equal(socks.personal, false); assert.equal(socks.user_id, null); }); test('PATCH checklist item: each field individually', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); const item = (await agent.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Passport' })).body.item; const text = await agent.patch(`/api/checklist/${item.id}`).send({ text: 'Passport (renewed)' }); assert.equal(text.status, 200); assert.equal(text.body.item.text, 'Passport (renewed)'); const category = await agent.patch(`/api/checklist/${item.id}`).send({ category: 'Documents' }); assert.equal(category.body.item.category, 'Documents'); const qty = await agent.patch(`/api/checklist/${item.id}`).send({ qty: 3 }); assert.equal(qty.body.item.qty, 3); const qtyCleared = await agent.patch(`/api/checklist/${item.id}`).send({ qty: null }); assert.equal(qtyCleared.body.item.qty, null); const sortOrder = await agent.patch(`/api/checklist/${item.id}`).send({ sort_order: 7 }); assert.equal(sortOrder.body.item.sort_order, 7); const personal = await agent.patch(`/api/checklist/${item.id}`).send({ personal: true }); assert.equal(personal.body.item.personal, true); const shared = await agent.patch(`/api/checklist/${item.id}`).send({ personal: false }); assert.equal(shared.body.item.personal, false); const checked = await agent.patch(`/api/checklist/${item.id}`).send({ checked: true }); assert.equal(checked.body.item.checked, true); }); test('DELETE checklist item -> 204, then absent from list', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); const item = (await agent.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Passport' })).body.item; const del = await agent.delete(`/api/checklist/${item.id}`); assert.equal(del.status, 204); const list = (await agent.get(`/api/trips/${trip.id}/checklist`)).body.items; assert.ok(!list.some((i) => i.id === item.id)); }); // --------------------------------------------------------------------------- // Ordering // --------------------------------------------------------------------------- test('ordering: fixed category order, then other categories alphabetically, then (sort_order, id)', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); const base = `/api/trips/${trip.id}/checklist`; // Created deliberately out of order. await agent.post(base).send({ text: 'e1', category: 'Extras' }); await agent.post(base).send({ text: 'z1', category: 'Zebra' }); await agent.post(base).send({ text: 'd-late', category: 'Documents', sort_order: 5 }); await agent.post(base).send({ text: 'c1', category: 'Clothing' }); await agent.post(base).send({ text: 'a1', category: 'Apple' }); await agent.post(base).send({ text: 'd-early', category: 'Documents', sort_order: 1 }); const items = (await agent.get(`/api/trips/${trip.id}/checklist`)).body.items; assert.deepEqual( items.map((i) => i.text), ['d-early', 'd-late', 'c1', 'e1', 'a1', 'z1'] ); }); test('ordering: (sort_order, id) breaks ties within a category', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); const base = `/api/trips/${trip.id}/checklist`; const a = (await agent.post(base).send({ text: 'a', category: 'General', sort_order: 3 })).body.item; const b = (await agent.post(base).send({ text: 'b', category: 'General', sort_order: 3 })).body.item; const items = (await agent.get(`/api/trips/${trip.id}/checklist`)).body.items; assert.deepEqual(items.map((i) => i.id), [a.id, b.id], 'equal sort_order falls back to id order'); }); // --------------------------------------------------------------------------- // Personal vs shared isolation // --------------------------------------------------------------------------- test('personal items are invisible to other members; shared items are visible to all and tickable by either', async () => { const { agent: a, user: userA } = await createAccount(); const trip = await makeTrip(a); const { agent: b, user: userB } = await createAccount(); await joinTrip(b, trip); const personalA = (await a.post(`/api/trips/${trip.id}/checklist`).send({ text: 'My medication', personal: true, })).body.item; assert.equal(personalA.user_id, userA.id); const shared = (await a.post(`/api/trips/${trip.id}/checklist`).send({ text: 'First-aid kit', })).body.item; const listForB = (await b.get(`/api/trips/${trip.id}/checklist`)).body.items; assert.ok(!listForB.some((i) => i.id === personalA.id), 'B must not see A\'s personal item'); assert.ok(listForB.some((i) => i.id === shared.id), 'B sees the shared item'); const listForA = (await a.get(`/api/trips/${trip.id}/checklist`)).body.items; assert.ok(listForA.some((i) => i.id === personalA.id)); assert.ok(listForA.some((i) => i.id === shared.id)); // B cannot PATCH or DELETE A's personal item. const patchAttempt = await b.patch(`/api/checklist/${personalA.id}`).send({ checked: true }); assert.equal(patchAttempt.status, 404); const deleteAttempt = await b.delete(`/api/checklist/${personalA.id}`); assert.equal(deleteAttempt.status, 404); // Either member can tick the shared item; checked_by records who. const bTicks = await b.patch(`/api/checklist/${shared.id}`).send({ checked: true }); assert.equal(bTicks.status, 200); assert.equal(bTicks.body.item.checked_by, userB.id); const aTicks = await a.patch(`/api/checklist/${shared.id}`).send({ checked: true }); assert.equal(aTicks.status, 200); assert.equal(aTicks.body.item.checked_by, userA.id); }); // --------------------------------------------------------------------------- // Access control // --------------------------------------------------------------------------- test('access control: non-member gets 404 on every trip-scoped and item-scoped route', async () => { const { agent: owner } = await createAccount(); const trip = await makeTrip(owner); const item = (await owner.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Passport' })).body.item; const { agent: outsider } = await createAccount(); assert.equal((await outsider.get(`/api/trips/${trip.id}/checklist`)).status, 404); assert.equal((await outsider.get(`/api/trips/${trip.id}/checklist/suggestions`)).status, 404); assert.equal( (await outsider.post(`/api/trips/${trip.id}/checklist/suggestions`).send({ keys: ['doc-passport'] })).status, 404 ); assert.equal((await outsider.post(`/api/trips/${trip.id}/checklist/reset`).send({})).status, 404); assert.equal( (await outsider.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Sneaky' })).status, 404 ); assert.equal((await outsider.patch(`/api/checklist/${item.id}`).send({ checked: true })).status, 404); assert.equal((await outsider.delete(`/api/checklist/${item.id}`)).status, 404); }); test('access control: unknown item id -> 404', async () => { const { agent } = await createAccount(); await makeTrip(agent); assert.equal((await agent.patch('/api/checklist/999999').send({ checked: true })).status, 404); assert.equal((await agent.delete('/api/checklist/999999')).status, 404); assert.equal((await agent.patch('/api/checklist/not-a-number').send({ checked: true })).status, 404); }); // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- test('validation: empty/whitespace text, text/category too long, bad qty -> 400', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); const base = `/api/trips/${trip.id}/checklist`; assert.equal((await agent.post(base).send({ text: '' })).status, 400); assert.equal((await agent.post(base).send({ text: ' ' })).status, 400); assert.equal((await agent.post(base).send({ text: 'x'.repeat(121) })).status, 400); assert.equal((await agent.post(base).send({ text: 'ok', category: 'x'.repeat(41) })).status, 400); assert.equal((await agent.post(base).send({ text: 'ok', qty: 0 })).status, 400); assert.equal((await agent.post(base).send({ text: 'ok', qty: 100 })).status, 400); assert.equal((await agent.post(base).send({ text: 'ok', qty: 2.5 })).status, 400); // Same checks apply to PATCH. const item = (await agent.post(base).send({ text: 'Passport' })).body.item; assert.equal((await agent.patch(`/api/checklist/${item.id}`).send({ text: ' ' })).status, 400); assert.equal((await agent.patch(`/api/checklist/${item.id}`).send({ qty: 0 })).status, 400); assert.equal((await agent.patch(`/api/checklist/${item.id}`).send({ category: 'x'.repeat(41) })).status, 400); }); // --------------------------------------------------------------------------- // checked_by semantics // --------------------------------------------------------------------------- test('checked_by: set to caller on checked:true, cleared on checked:false', async () => { const { agent, user } = await createAccount(); const trip = await makeTrip(agent); const item = (await agent.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Passport' })).body.item; const checked = await agent.patch(`/api/checklist/${item.id}`).send({ checked: true }); assert.equal(checked.body.item.checked, true); assert.equal(checked.body.item.checked_by, user.id); const unchecked = await agent.patch(`/api/checklist/${item.id}`).send({ checked: false }); assert.equal(unchecked.body.item.checked, false); assert.equal(unchecked.body.item.checked_by, null); }); // --------------------------------------------------------------------------- // Reset // --------------------------------------------------------------------------- test('reset: unticks only the caller\'s visible items and returns the count; another member\'s personal item is untouched', async () => { const { agent: a, user: userA } = await createAccount(); const trip = await makeTrip(a); const { agent: b, user: userB } = await createAccount(); await joinTrip(b, trip); const shared = (await a.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Shared', checked: true })).body.item; const personalA = (await a.post(`/api/trips/${trip.id}/checklist`).send({ text: 'A personal', personal: true, checked: true, })).body.item; const personalB = (await b.post(`/api/trips/${trip.id}/checklist`).send({ text: 'B personal', personal: true, checked: true, })).body.item; const res = await a.post(`/api/trips/${trip.id}/checklist/reset`).send({}); assert.equal(res.status, 200); assert.equal(res.body.unchecked, 2, 'shared + A\'s own personal item'); const listForA = (await a.get(`/api/trips/${trip.id}/checklist`)).body.items; assert.equal(listForA.find((i) => i.id === shared.id).checked, false); assert.equal(listForA.find((i) => i.id === shared.id).checked_by, null); assert.equal(listForA.find((i) => i.id === personalA.id).checked, false); const listForB = (await b.get(`/api/trips/${trip.id}/checklist`)).body.items; const bPersonalStillChecked = listForB.find((i) => i.id === personalB.id); assert.equal(bPersonalStillChecked.checked, true); assert.equal(bPersonalStillChecked.checked_by, userB.id); // Sanity: userA is distinct from userB so we know isolation, not coincidence. assert.notEqual(userA.id, userB.id); });