Initial release: collaborative trip planner
Multi-user trip planning web app in a single Docker container. Mullvad-style token accounts, trip sharing via join codes, day-by-day calendar with typed entries (activity, hotel, travel, flight, rental car, immigration, note), multi-leg flight segments with bundled IATA airport dataset, Leaflet/OSM map with per-leg great-circle km (air vs ground), rough km-driven vs rental included-km comparison, cost splitting with settle-up suggestions, flip-clock departure countdown. Node 20 + Express + SQLite (WAL, additive migrations), vanilla JS SPA, 44 API tests.
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
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-rental-'));
|
||||
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: 'R', start_date: '2026-08-01', end_date: '2026-08-10' })).body.trip;
|
||||
}
|
||||
|
||||
const RENTAL = {
|
||||
brand: 'Toyota', model: 'Yaris Cross', car_type: 'SUV',
|
||||
booking_ref: 'RC-889231', included_km: 1500,
|
||||
pickup: { date: '2026-08-01', time: '09:00', location_name: 'CNX Airport', lat: 18.77, lng: 98.96 },
|
||||
dropoff: { date: '2026-08-07', time: '18:00', location_name: 'Old Town', lat: 18.79, lng: 98.98 },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rental validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('rental: accepted on rental entry, parsed back, cleared via null', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
|
||||
const res = await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'Car', rental: RENTAL,
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
const r = res.body.entry.rental;
|
||||
assert.equal(r.brand, 'Toyota');
|
||||
assert.equal(r.included_km, 1500);
|
||||
assert.equal(r.pickup.location_name, 'CNX Airport');
|
||||
assert.equal(r.dropoff.date, '2026-08-07');
|
||||
|
||||
const cleared = await agent.patch(`/api/entries/${res.body.entry.id}`).send({ rental: null });
|
||||
assert.equal(cleared.status, 200);
|
||||
assert.equal(cleared.body.entry.rental, null);
|
||||
});
|
||||
|
||||
test('rental: rejected on non-rental entries', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const res = await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'hotel', title: 'Nope', rental: RENTAL,
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test('rental: bad shapes rejected', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const base = `/api/trips/${trip.id}/entries`;
|
||||
|
||||
const longBrand = await agent.post(base).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'x', rental: { brand: 'z'.repeat(61) },
|
||||
});
|
||||
assert.equal(longBrand.status, 400);
|
||||
|
||||
const negKm = await agent.post(base).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'x', rental: { included_km: -5 },
|
||||
});
|
||||
assert.equal(negKm.status, 400);
|
||||
|
||||
const noDate = await agent.post(base).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'x', rental: { pickup: { time: '09:00' } },
|
||||
});
|
||||
assert.equal(noDate.status, 400);
|
||||
|
||||
const halfCoord = await agent.post(base).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'x', rental: { pickup: { date: '2026-08-01', lat: 18.77 } },
|
||||
});
|
||||
assert.equal(halfCoord.status, 400);
|
||||
});
|
||||
|
||||
test('rental: minimal object with only included_km null is accepted', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const res = await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'Car', rental: { brand: 'Kia', included_km: null },
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
assert.equal(res.body.entry.rental.included_km, null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary + costs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('route summary: rentals count and includedKm sum', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'Car A', rental: { included_km: 1500 },
|
||||
});
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-05', type: 'rental', title: 'Car B', rental: { included_km: 300 },
|
||||
});
|
||||
|
||||
const res = await agent.get(`/api/trips/${trip.id}/route`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.summary.rentals, 2);
|
||||
assert.equal(res.body.summary.includedKm, 1800);
|
||||
});
|
||||
|
||||
test('route summary: includedKm is null when no rental specifies one', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'Car', rental: { brand: 'Kia' },
|
||||
});
|
||||
const res = await agent.get(`/api/trips/${trip.id}/route`);
|
||||
assert.equal(res.body.summary.rentals, 1);
|
||||
assert.equal(res.body.summary.includedKm, null);
|
||||
});
|
||||
|
||||
test('costs byType includes rental', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'Car', price: 240, rental: { brand: 'Kia' },
|
||||
});
|
||||
const c = (await agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||
assert.equal(c.totalCost, 240);
|
||||
assert.equal(c.byType.rental, 240);
|
||||
});
|
||||
Reference in New Issue
Block a user