// Handler-level tests for the queueing research route (Task 5a(ii)): POST /api/research with // a crew team queues a RESEARCH row instead of resolving instantly — the tech stays // unresearched until the task completes on the turn tick, the cost comes out of the cargo // hold at queue time, same snapshot shape, same pre-check codes, no turn spent at queue time. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { afterAll, describe, expect, it } from 'vitest' import { getGameDb, type GameDb } from '@/lib/db/client' import * as schema from '@/lib/db/schema' import { getTechState } from '@/lib/game/research' import { TECHS, type TechDef } from '@/lib/game/tech' import { addCrew, buildWorldFixture } from '@/lib/game/test-fixtures' import { POST } from './route' const dirs: string[] = [] // The route talks to the app-process singleton (getGameDb), so each test points it at a // fresh temp file and uses that very instance for fixtures and assertions — two in-memory // sql.js copies of one file would never see each other's writes. const g = globalThis as unknown as { __spaceWandererGameDb?: Promise } async function freshFile(): Promise<{ db: GameDb; file: string }> { const dir = await mkdtemp(path.join(tmpdir(), 'sw-research-route-test-')) dirs.push(dir) const file = path.join(dir, 'test.db') process.env.SPACE_WANDERER_DB_PATH = file delete g.__spaceWandererGameDb return { db: await getGameDb(), file } } afterAll(async () => { delete process.env.SPACE_WANDERER_DB_PATH delete g.__spaceWandererGameDb for (const d of dirs) await rm(d, { recursive: true, force: true }) }) const post = (body: unknown) => POST(new Request('http://localhost/api/research', { method: 'POST', body: JSON.stringify(body) })) // The fixture suite mints no tech rows (JC-6) — insert exactly what the test needs from the // pure TECHS definitions (WEAPONS_SYSTEMS: part cost, no prerequisites). The happy path seeds // every row, like the new-game backfill: the snapshot read (getTechState) maps every TECHS def. async function insertTech(db: GameDb, def: TechDef, over: { researched?: boolean } = {}) { const [save] = await db.db.select().from(schema.saves).limit(1) const [row] = await db.db .insert(schema.tech) .values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect, researched: over.researched ?? false }) .returning() return row } // The fixture suite mints no cargo hold (JC-6) — seed rows directly. async function seedHold(db: GameDb, rows: { kind: 'resource' | 'part'; name: string; qty: number }[]) { const [save] = await db.db.select().from(schema.saves).limit(1) for (const r of rows) { await db.db.insert(schema.inventoryItems).values({ saveId: save.id, ...r }) } } describe('POST /api/research (5a(ii))', () => { it('happy: row with type/payload, turn unchanged, cost deducted, tech not researched yet', async () => { const { db } = await freshFile() await buildWorldFixture(db) const [c1] = await addCrew(db, 1) const def = TECHS[0] // WEAPONS_SYSTEMS — the tech row's cost comes from its TECHS def for (const d of TECHS) await insertTech(db, d) // every row, like the new-game backfill const tech = (await db.db.select().from(schema.tech)).find((t) => t.slug === def.slug)! await seedHold(db, [{ kind: def.costKind, name: def.costKind === 'resource' ? 'resource' : 'part', qty: def.costQty }]) const [before] = await db.db.select().from(schema.saves) const res = await post({ techId: tech.id, leadCrewId: c1 }) expect(res.status).toBe(200) expect(await res.json()).toEqual(await getTechState(db)) // the same snapshot shape the instant action returned const [row] = await db.db.select().from(schema.tasks) expect(row).toMatchObject({ type: 'RESEARCH', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn }) expect(JSON.parse(row.helpers)).toEqual([]) // helpers is a JSON text column expect(JSON.parse(row.payload)).toEqual({ techId: tech.id }) expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment expect(await db.db.select().from(schema.inventoryItems)).toHaveLength(0) // whole cost deducted at queue time expect((await db.db.select().from(schema.tech)).find((t) => t.id === tech.id)!.researched).toBe(false) // the effect waits for the tick }) it('unaffordable → 422 (existing spendItems message), nothing queued', async () => { const { db } = await freshFile() await buildWorldFixture(db) const [c1] = await addCrew(db, 1) const def = TECHS[0] const tech = await insertTech(db, def) await seedHold(db, [{ kind: def.costKind, name: def.costKind === 'resource' ? 'resource' : 'part', qty: def.costQty - 1 }]) const res = await post({ techId: tech.id, leadCrewId: c1 }) expect(res.status).toBe(422) expect(await db.db.select().from(schema.tasks)).toHaveLength(0) }) it('already researched → 409 (existing pre-check, existing code), nothing queued', async () => { const { db } = await freshFile() await buildWorldFixture(db) const [c1] = await addCrew(db, 1) const def = TECHS[0] const tech = await insertTech(db, def, { researched: true }) expect((await post({ techId: tech.id, leadCrewId: c1 })).status).toBe(409) expect(await db.db.select().from(schema.tasks)).toHaveLength(0) }) })