import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm } from '@/lib/geo'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; import { GET } from './route'; let dir: string; beforeAll(async () => { // Point the app's DB singleton at a throwaway file so this test never // touches ./db/flight.db. getGameDb() reads the env var on first call, and // vitest gives each test file its own module registry (fresh singleton). dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-state-')); process.env.FLIGHT_DB_PATH = path.join(dir, 'state.db'); }); afterAll(async () => { delete process.env.FLIGHT_DB_PATH; await fs.rm(dir, { recursive: true, force: true }); }); describe('GET /api/state', () => { it('returns 200 with the full snapshot shape', async () => { const res = await GET(); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toContain('application/json'); const body = (await res.json()) as Record; expect(Object.keys(body).sort()).toEqual([ 'account', 'catalog', 'fuelPricePerL', 'packages', 'planes', 'sellFraction', 'simState', ]); const account = body.account as { cash: number; reputation: number }; expect(typeof account.cash).toBe('number'); expect(typeof account.reputation).toBe('number'); const sim = body.simState as { clockMs: number }; // ISS-117: time controls are gone — clock only expect(typeof sim.clockMs).toBe('number'); const planes = body.planes as Array>; expect(planes.length).toBeGreaterThanOrEqual(1); for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon']) { expect(planes[0], `plane.${key}`).toHaveProperty(key); } expect(typeof planes[0].locationName).toBe('string'); // The seeded starter is grounded — no flight destination yet. expect(planes[0].destIcao).toBeNull(); expect(planes[0].destName).toBeNull(); const packages = body.packages as Array>; expect(packages.length).toBeGreaterThan(0); for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status']) { expect(packages[0], `package.${key}`).toHaveProperty(key); } // v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the // fixture airports' coordinates for every package in the snapshot. const fc = await loadAirportsGeoJson(); const coords = new Map(fc.features.map((f) => [f.properties.icao, f.properties])); for (const pkg of packages) { const from = coords.get(String(pkg.originIcao)); const to = coords.get(String(pkg.destIcao)); if (!from || !to) continue; // missing coord → 0 fallback (cannot happen with the committed dataset) expect(pkg.distanceKm, `package ${String(pkg.id)} distance`).toBeCloseTo(haversineKm(from.lat, from.lon, to.lat, to.lon), 6); } }); it('is stable across repeated calls (same seeded DB)', async () => { const a = ((await GET()).json()) as Promise<{ account: { cash: number } }>; const b = ((await GET()).json()) as Promise<{ account: { cash: number } }>; expect((await b).account.cash).toBe((await a).account.cash); }); it('exposes the catalog + economy constants matching lib/game/catalog.ts (v0.10.0)', async () => { const body = ((await GET()).json()) as Promise<{ catalog: Array>; fuelPricePerL: number; sellFraction: number; }>; const snap = await body; // One entry per PLANE_TYPES model, values copied straight from the catalog. expect(snap.catalog.map((e) => e.model).sort()).toEqual(Object.keys(PLANE_TYPES).sort()); for (const [model, type] of Object.entries(PLANE_TYPES)) { const entry = snap.catalog.find((e) => e.model === model)!; expect(entry.label).toBe(type.name); expect(entry.price).toBe(type.price); expect(entry.maxFuel).toBe(type.maxFuel); expect(entry.burnPerKm).toBe(type.burnPerKm); expect(entry.speedKmh).toBe(type.speedKmh); expect(entry.maxPayloadKg).toBe(type.maxPayloadKg); // v0.27.0 payload kg (ATR 72-600: 4800) } // Economy constants — the single source of truth stays lib/game/catalog.ts. expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L); expect(snap.sellFraction).toBe(SELL_FRACTION); }); });