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 { getGameDb } from '@/lib/db/client'; import * as schema from '@/lib/db/schema'; import { POST } from './route'; let dir: string; beforeAll(async () => { // Same pattern as app/api/state/route.test.ts: point the DB singleton at a // throwaway file (fresh module registry per test file → fresh singleton). dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-api-')); process.env.FLIGHT_DB_PATH = path.join(dir, 'sim.db'); }); afterAll(async () => { delete process.env.FLIGHT_DB_PATH; await fs.rm(dir, { recursive: true, force: true }); }); const post = (body: unknown) => POST(new Request('http://localhost/api/sim', { method: 'POST', body: JSON.stringify(body) })); const postRaw = (text: string) => POST(new Request('http://localhost/api/sim', { method: 'POST', body: text })); describe('POST /api/sim', () => { it('returns the current simState for an empty body (tick + no-op)', async () => { const res = await post({}); expect(res.status).toBe(200); const body = (await res.json()) as { clockMs: number; timeScale: number; paused: boolean }; expect(typeof body.clockMs).toBe('number'); expect(body.timeScale).toBe(1); // seeded default expect(body.paused).toBe(false); }); it('sets and clamps timeScale into [1, 1000]', async () => { const hi = await post({ timeScale: 2000 }); expect(hi.status).toBe(200); expect(((await hi.json()) as { timeScale: number }).timeScale).toBe(1000); const lo = await post({ timeScale: 0.1 }); expect(lo.status).toBe(200); expect(((await lo.json()) as { timeScale: number }).timeScale).toBe(1); // The top of the ladder passes through unclamped (v0.14.0). const max = await post({ timeScale: 1000 }); expect(max.status).toBe(200); expect(((await max.json()) as { timeScale: number }).timeScale).toBe(1000); // In-range values pass through unclamped — ladder and off-ladder alike. const ladder = await post({ timeScale: 500 }); expect(ladder.status).toBe(200); expect(((await ladder.json()) as { timeScale: number }).timeScale).toBe(500); const mid = await post({ timeScale: 37 }); expect(mid.status).toBe(200); expect(((await mid.json()) as { timeScale: number }).timeScale).toBe(37); // Restore the default for later tests. await post({ timeScale: 1 }); }); it('toggles paused and freezes the clock across ticks', async () => { const pause = await post({ paused: true }); expect(pause.status).toBe(200); expect(((await pause.json()) as { paused: boolean }).paused).toBe(true); // Two reads while paused → identical clock (tick-on-read advances nothing). const a = await post({}); const b = await post({}); expect(((await a.json()) as { clockMs: number }).clockMs).toBe( ((await b.json()) as { clockMs: number }).clockMs, ); const resume = await post({ paused: false }); expect(resume.status).toBe(200); expect(((await resume.json()) as { paused: boolean }).paused).toBe(false); }); it('applies both fields in one call', async () => { const res = await post({ timeScale: 64, paused: true }); expect(res.status).toBe(200); const body = (await res.json()) as { timeScale: number; paused: boolean }; expect(body.timeScale).toBe(64); expect(body.paused).toBe(true); await post({ timeScale: 1, paused: false }); // restore }); it('records lastSelectedTimeScale when timeScale is posted; a paused-only post leaves it untouched (ISS-057)', async () => { const inst = await getGameDb(); // same singleton the route's applyTimeControls writes through const row = async () => (await inst.db.select().from(schema.simState))[0]; await post({ timeScale: 250 }); expect((await row()).lastSelectedTimeScale).toBe(250); // manual intent recorded… await post({ paused: true }); expect((await row()).paused).toBe(true); expect((await row()).lastSelectedTimeScale).toBe(250); // …and a {paused}-only post does not touch it await post({ timeScale: 1, paused: false }); // restore expect((await row()).lastSelectedTimeScale).toBe(1); }); it('rejects invalid input with 400', async () => { for (const body of [{ timeScale: 'fast' }, { timeScale: Infinity }, { paused: 'yes' }, { bogus: 1 }]) { const res = await post(body); expect(res.status).toBe(400); expect(((await res.json()) as { error: string }).error).toBeTruthy(); } for (const text of ['nope{', '[1,2]', '"str"', 'null']) { const res = await postRaw(text); expect(res.status).toBe(400); } }); });