import { test } from 'node:test'; import assert from 'node:assert/strict'; import { buildApp, type BuildOptions } from '../src/app.ts'; const TOKEN = 'test-admin-token-0123456789'; const auth = { authorization: `Bearer ${TOKEN}` }; const make = (o: Partial = {}) => buildApp({ adminToken: TOKEN, dbPath: ':memory:', ...o }); const line = (id: string, kind = 'free_2h') => ({ id, kind, name: `${id} name`, label: '2-hour free', legend: 'Permits not valid', hours: 2, color: '#d367cc', geometry: { type: 'LineString', coordinates: [ [-116.5535, 48.2766], [-116.5525, 48.2766], ], }, }); test('areas are public to read and admin-only to replace', async () => { const app = await make(); let r = await app.inject({ method: 'GET', url: '/api/areas' }); assert.equal(r.statusCode, 200); assert.deepEqual(r.json().areas, []); assert.equal(r.json().count, 0); r = await app.inject({ method: 'PUT', url: '/api/areas', payload: { areas: [line('sp-001')] } }); assert.equal(r.statusCode, 401); r = await app.inject({ method: 'PUT', url: '/api/areas', headers: auth, payload: { areas: [line('sp-001'), line('sp-002', 'limit_3h')] }, }); assert.equal(r.statusCode, 200); assert.equal(r.json().replaced, 2); r = await app.inject({ method: 'GET', url: '/api/areas' }); const areas = r.json().areas; assert.equal(areas.length, 2); assert.equal(areas[0].id, 'sp-001'); assert.equal(areas[0].kind, 'free_2h'); assert.equal(areas[0].shape, 'line'); // Geometry survives the round-trip as real GeoJSON, not a string. assert.deepEqual(areas[0].geometry.coordinates[0], [-116.5535, 48.2766]); await app.close(); }); test('replace is wholesale — stale areas do not survive', async () => { const app = await make(); await app.inject({ method: 'PUT', url: '/api/areas', headers: auth, payload: { areas: [line('sp-001'), line('sp-002')] }, }); await app.inject({ method: 'PUT', url: '/api/areas', headers: auth, payload: { areas: [line('sp-003')] }, }); const r = await app.inject({ method: 'GET', url: '/api/areas' }); assert.equal(r.json().count, 1); assert.equal(r.json().areas[0].id, 'sp-003'); await app.close(); }); test('polygons are accepted; bad kinds and geometries are rejected', async () => { const app = await make(); const lot = { id: 'sp-022', kind: 'green_lot', name: 'Lot off Oak St', label: 'City lot', legend: 'Paid hourly or permit', hours: 2, color: '#75b259', geometry: { type: 'Polygon', coordinates: [ [ [-116.554, 48.2766], [-116.553, 48.2766], [-116.553, 48.2772], [-116.554, 48.2766], ], ], }, }; let r = await app.inject({ method: 'PUT', url: '/api/areas', headers: auth, payload: { areas: [lot] }, }); assert.equal(r.statusCode, 200); assert.equal(r.json().replaced, 1); r = await app.inject({ method: 'GET', url: '/api/areas' }); assert.equal(r.json().areas[0].shape, 'polygon'); r = await app.inject({ method: 'PUT', url: '/api/areas', headers: auth, payload: { areas: [{ ...line('sp-009'), kind: 'free_9h' }] }, }); assert.equal(r.statusCode, 400); assert.equal(r.json().error, 'bad_kind'); r = await app.inject({ method: 'PUT', url: '/api/areas', headers: auth, payload: { areas: [{ ...line('sp-009'), geometry: { type: 'Point', coordinates: [0, 0] } }] }, }); assert.equal(r.statusCode, 400); assert.equal(r.json().error, 'bad_geometry'); // A rejected batch must not have clobbered the good one. r = await app.inject({ method: 'GET', url: '/api/areas' }); assert.equal(r.json().count, 1); assert.equal(r.json().areas[0].id, 'sp-022'); await app.close(); }); test('requiresAccount is null unless explicitly overridden', async () => { const app = await make(); await app.inject({ method: 'PUT', url: '/api/areas', headers: auth, payload: { areas: [ line('sp-001'), // no override -> client decides by category { ...line('sp-002', 'green_lot'), requiresAccount: false }, // a lot that needs no account { ...line('sp-003'), requiresAccount: true }, ], }, }); const areas = (await app.inject({ method: 'GET', url: '/api/areas' })).json().areas; const by = Object.fromEntries(areas.map((a: any) => [a.id, a.requiresAccount])); assert.equal(by['sp-001'], null, 'no override should stay null, not become false'); assert.equal(by['sp-002'], false); assert.equal(by['sp-003'], true); await app.close(); }); test('a database created before requires_account existed still works', async () => { // Simulate an older deployment: build the table without the column, then let // the migration add it. CREATE TABLE IF NOT EXISTS alone would not. const Database = (await import('better-sqlite3')).default; const file = `/tmp/bbp-migrate-${process.pid}.db`; const raw = new Database(file); raw.exec(`CREATE TABLE parking_areas ( id TEXT PRIMARY KEY, kind TEXT NOT NULL, name TEXT NOT NULL, label TEXT NOT NULL, legend TEXT NOT NULL, hours REAL NOT NULL, color TEXT NOT NULL, shape TEXT NOT NULL, geometry TEXT NOT NULL, updated_at INTEGER NOT NULL)`); raw.close(); const app = await make({ dbPath: file }); const r = await app.inject({ method: 'PUT', url: '/api/areas', headers: auth, payload: { areas: [{ ...line('sp-009'), requiresAccount: true }] }, }); assert.equal(r.statusCode, 200); const areas = (await app.inject({ method: 'GET', url: '/api/areas' })).json().areas; assert.equal(areas[0].requiresAccount, true); await app.close(); (await import('node:fs')).rmSync(file, { force: true }); }); test('overlay defaults to identity and round-trips', async () => { const app = await make(); let r = await app.inject({ method: 'GET', url: '/api/areas' }); assert.deepEqual(r.json().overlay, { dxMeters: 0, dyMeters: 0, scale: 1, rotationDeg: 0, updatedAt: 0, }); r = await app.inject({ method: 'PUT', url: '/api/areas/overlay', payload: { dxMeters: 3 }, }); assert.equal(r.statusCode, 401); r = await app.inject({ method: 'PUT', url: '/api/areas/overlay', headers: auth, payload: { dxMeters: 3.5, dyMeters: -2, scale: 1.01, rotationDeg: 0.4 }, }); assert.equal(r.statusCode, 200); assert.equal(r.json().dxMeters, 3.5); assert.ok(r.json().updatedAt > 0); r = await app.inject({ method: 'GET', url: '/api/areas' }); assert.equal(r.json().overlay.dyMeters, -2); assert.equal(r.json().overlay.rotationDeg, 0.4); await app.close(); }); test('an absurd overlay scale is refused rather than stored', async () => { const app = await make(); for (const scale of [0, 0.4, 2, 5]) { const r = await app.inject({ method: 'PUT', url: '/api/areas/overlay', headers: auth, payload: { scale }, }); assert.equal(r.statusCode, 400, `scale ${scale} should be refused`); } const r = await app.inject({ method: 'GET', url: '/api/areas' }); assert.equal(r.json().overlay.scale, 1); await app.close(); });