Add trip checklists with rule-based packing advice
Each trip gets a checklist whose items group under free-text categories (Documents, Clothing, Toiletries, Health, Electronics, Extras first, then any custom ones alphabetically). Items are either shared — every member sees and can tick them, and checked_by records who — or personal to one member, which nobody else can see or touch. Items carry an optional quantity, drag-reorder within their category, and "Uncheck all" resets the list for the trip home. The "Suggestions" modal is deterministic, offline advice derived from the trip itself (src/server/util/packing.js) — no LLM and no external calls, so it stays unit-testable and works on a self-hosted box. Nights scale clothing quantities, flights add liquids/power-bank/check-in, rentals add licence + IDP, ferries add motion-sickness tablets, tropical stops add sun cream and repellent, and the destination country picks the plug type from a bundled ~50-country table. Every suggestion carries a short reason, and already-added ones are keyed by suggestion_key so they can't be duplicated. Two rules deliberately differ from the naive reading, both regression-tested: a latitude floor stops a December trip to Bangkok being tagged cold as well as tropical, and only a flight segment's arrival airport counts, since the first segment's departure airport is home rather than a destination. checklist_items is a new table, so the existing CREATE TABLE IF NOT EXISTS path creates it on upgrade; no MIGRATIONS entry is needed and existing data is untouched. docs/API.md documents the full contract. 113/113 tests pass.
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
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);
|
||||
});
|
||||
@@ -7,6 +7,8 @@
|
||||
// top-level beforeEach/afterEach that touch only its own module-level
|
||||
// app/tmpDir — keeping the files independent.
|
||||
import './api.test.js';
|
||||
import './checklist.test.js';
|
||||
import './checklist-suggestions.test.js';
|
||||
import './costs.test.js';
|
||||
import './flights.test.js';
|
||||
import './rental.test.js';
|
||||
|
||||
Reference in New Issue
Block a user