All checks were successful
build-apk / build (push) Successful in 9m54s
The green lots are the map's only paid category ("City lots - Paid hourly or
permit"); paying for them goes through ParkSmarter, so they are no use to
someone browsing without an account. Every other category is free street
parking with a posted time limit and needs nothing.
Gated by category rather than by an id list. Two lots were named (off Oak St
and off N 3rd Ave) and then the beach ones, which together is every green lot
on the map — and an id list would silently break the next time the map is
regenerated from a new PDF, since ids are positional.
- Paid lots are filtered out of the overlay when signed out, so they are
neither drawn nor tappable.
- "Park here" still detects them, so standing in one explains that it needs an
account instead of reporting no parking nearby.
- The area screen guards too, in case one is reached with a stale nav param.
- Server carries an optional per-area requiresAccount override for a lot that
turns out to take payment another way. Null means "use the category
default", so an unset value can't be confused with an explicit false.
The new column needs a real migration: CREATE TABLE IF NOT EXISTS does not add
a column to a table that already exists, so an already-deployed server would
have kept the old schema. Covered by a test that builds the pre-migration
table and then opens it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
248 lines
7.1 KiB
TypeScript
248 lines
7.1 KiB
TypeScript
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<BuildOptions> = {}) =>
|
|
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();
|
|
});
|