Files
SpaceWanderer/app/api/jump/route.test.ts
T

110 lines
6.0 KiB
TypeScript

// Handler-level tests for the queueing jump route (Task 5a): POST /api/jump with a crew
// team queues a JUMP row instead of resolving instantly — same snapshots, 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 { getWorldState } from '@/lib/game/save'
import { addCrew, buildWorldFixture, insertTaskRow } 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<GameDb> }
async function freshFile(): Promise<{ db: GameDb; file: string }> {
const dir = await mkdtemp(path.join(tmpdir(), 'sw-jump-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/jump', { method: 'POST', body: JSON.stringify(body) }))
describe('POST /api/jump (5a)', () => {
it('queues a JUMP: row with type/payload/status, turn unchanged, snapshot preserved, ship not moved', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1, c2] = await addCrew(db, 2)
const [before] = await db.db.select().from(schema.saves)
const res = await post({ systemId: f.sys1, leadCrewId: c1, helperCrewIds: [c2] })
expect(res.status).toBe(200)
expect(await res.json()).toEqual(await getWorldState(db)) // the same fresh snapshot as today — queueing changes nothing else
const [row] = await db.db.select().from(schema.tasks)
expect(row).toMatchObject({ type: 'JUMP', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn })
expect(JSON.parse(row.helpers)).toEqual([c2]) // helpers is a JSON text column
expect(JSON.parse(row.payload)).toEqual({ systemId: f.sys1, hops: 1 }) // route-computed via the existing reachability logic
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment
expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the effect waits for the tick
})
it('unknown crew → 404 (queueTask)', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
expect((await post({ systemId: f.sys1, leadCrewId: 999 })).status).toBe(404)
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
})
it('busy helper → 409, nothing queued', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1, c2] = await addCrew(db, 2)
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
expect((await post({ systemId: f.sys1, leadCrewId: c1, helperCrewIds: [c2] })).status).toBe(409)
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
})
it('a jump in transit → 409 (blocks any new task)', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1, c2] = await addCrew(db, 2)
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c2, helpers: [], duration: 3, remaining: 3, status: 'ACTIVE', queuedAtTurn: 1 })
expect((await post({ systemId: f.sys1, leadCrewId: c1 })).status).toBe(409)
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
})
it('a jump already pending → second jump 409', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1, c2] = await addCrew(db, 2)
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 1 }) // lead chains behind its own ACTIVE
expect((await post({ systemId: f.sys1, leadCrewId: c2 })).status).toBe(409)
expect(await db.db.select().from(schema.tasks)).toHaveLength(2)
})
it('unreachable target → 409 (existing pre-check, existing code)', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1] = await addCrew(db, 1)
expect((await post({ systemId: f.farSystem, leadCrewId: c1 })).status).toBe(409) // JC-4: a beyond-reach system in the fixture; add it to buildWorldFixture if absent
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
})
it('malformed crew fields → 400, nothing queued', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
expect((await post({ systemId: f.sys1 })).status).toBe(400) // leadCrewId is required
expect((await post({ systemId: f.sys1, leadCrewId: 'x' })).status).toBe(400)
expect((await post({ systemId: f.sys1, leadCrewId: 1.5 })).status).toBe(400)
expect((await post({ systemId: f.sys1, leadCrewId: 1, helperCrewIds: 'nope' })).status).toBe(400)
expect((await post({ systemId: f.sys1, leadCrewId: 1, helperCrewIds: [1, 'x'] })).status).toBe(400)
expect((await post('not json at all' as unknown)).status).toBe(400)
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
})
})