feat: v0.6.0 — data foundation: Drizzle ORM + SQLite (sql.js/WASM) persistence, first-run seed, read-only GET /api/state snapshot

This commit is contained in:
2026-09-17 11:51:42 +00:00
parent 4d9a1e9fa9
commit 7fdc43bc7e
23 changed files with 1006 additions and 8 deletions
+2 -2
View File
@@ -13,12 +13,12 @@ test('GET /api/version returns the app version from package.json', async ({ page
expect(body.version).toBe(pkg.version);
});
test('GET /api/openapi serves the spec with both endpoints and the package.json version', async ({ page }) => {
test('GET /api/openapi serves the spec with all three endpoints and the package.json version', async ({ page }) => {
const res = await page.request.get('/api/openapi');
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/json');
const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record<string, unknown> };
expect(spec.info.title).toBe('Flight Ops API');
expect(spec.info.version).toBe(pkg.version);
expect(Object.keys(spec.paths).sort()).toEqual(['/api/airports', '/api/version']);
expect(Object.keys(spec.paths).sort()).toEqual(['/api/airports', '/api/state', '/api/version']);
});
+75
View File
@@ -0,0 +1,75 @@
import { execSync } from 'node:child_process';
import path from 'node:path';
import { test, expect } from '@playwright/test';
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on
// this host (the test runner), browsers stay remote.
const repoRoot = path.join(__dirname, '..');
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: Array<Record<string, unknown>>;
contracts: Array<Record<string, unknown>>;
}
test('GET /api/state returns a seeded snapshot', async ({ page }) => {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/json');
const body = (await res.json()) as StateBody;
expect(typeof body.account.cash).toBe('number');
expect(typeof body.account.reputation).toBe('number');
expect(typeof body.simState.clockMs).toBe('number');
expect(typeof body.simState.timeScale).toBe('number');
expect(body.simState.paused).toBe(false);
expect(Array.isArray(body.planes)).toBe(true);
expect(body.planes.length).toBeGreaterThanOrEqual(1);
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'lat', 'lon']) {
expect(body.planes[0], `plane.${key}`).toHaveProperty(key);
}
expect(Array.isArray(body.contracts)).toBe(true);
expect(body.contracts.length).toBeGreaterThan(0);
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'deadlineSimTime', 'status']) {
expect(body.contracts[0], `contract.${key}`).toHaveProperty(key);
}
});
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
test.setTimeout(180_000);
const resBefore = await page.request.get('/api/state');
expect(resBefore.status()).toBe(200);
const before = (await resBefore.json()) as StateBody;
execSync('docker compose restart flight', { cwd: repoRoot, stdio: 'pipe' });
// Wait for the app to come back, capturing the post-restart snapshot.
let after: StateBody | undefined;
await expect
.poll(async () => {
try {
const res = await page.request.get('/api/state');
if (res.status() === 200) after = (await res.json()) as StateBody;
return !!after;
} catch {
return false; // connection refused / 502 while the container starts
}
}, { timeout: 120_000, message: 'waiting for /api/state after docker compose restart' })
.toBe(true);
expect(after).toBeDefined();
const a = after!;
// The sim clock and account must be byte-identical — the DB file on the
// volume is loaded, not re-seeded (PLAN.md: "sim clock persisted across sessions").
expect(a.simState.clockMs).toBe(before.simState.clockMs);
expect(a.account.cash).toBe(before.account.cash);
expect(a.account.reputation).toBe(before.account.reputation);
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
expect(a.contracts.map((c) => c.id).sort()).toEqual(before.contracts.map((c) => c.id).sort());
});