97 lines
5.0 KiB
TypeScript
97 lines
5.0 KiB
TypeScript
// Handler-level tests for the queueing ship-build route (Task 5a(ii)): POST /api/ship/build
|
|
// with a crew team queues a SHIP_BUILD row instead of resolving instantly — the build at
|
|
// base values waits for the turn tick, the flat parts 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 { getShipState } from '@/lib/game/ship'
|
|
import { BUILD_PART_COST } from '@/lib/game/ship-costs'
|
|
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<GameDb> }
|
|
|
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-ship-build-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/ship/build', { method: 'POST', body: JSON.stringify(body) }))
|
|
|
|
// The fixture suite mints no ship rows (JC-6) — insert exactly what the test needs. HULL is
|
|
// the only type that is unbuilt by default AND has no build gate (WEAPONS is gated behind
|
|
// the WEAPONS SYSTEMS tech, which the fixture leaves unresearched).
|
|
async function insertSystem(db: GameDb, over: { type: string; level: number; capacity: number }) {
|
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
|
const [row] = await db.db.insert(schema.shipSystems).values({ saveId: save.id, ...over }).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/ship/build (5a(ii))', () => {
|
|
it('happy: row with type/payload, turn unchanged, parts deducted, not built yet', async () => {
|
|
const { db } = await freshFile()
|
|
await buildWorldFixture(db)
|
|
const [c1] = await addCrew(db, 1)
|
|
const sys = await insertSystem(db, { type: 'HULL', level: 0, capacity: 0 }) // unbuilt, no gate
|
|
await seedHold(db, [{ kind: 'part', name: 'part', qty: BUILD_PART_COST }]) // exactly the existing flat cost
|
|
const [before] = await db.db.select().from(schema.saves)
|
|
|
|
const res = await post({ type: sys.type, leadCrewId: c1 })
|
|
expect(res.status).toBe(200)
|
|
expect(await res.json()).toEqual(await getShipState(db)) // the same snapshot shape the instant action returned
|
|
const [row] = await db.db.select().from(schema.tasks)
|
|
expect(row).toMatchObject({ type: 'SHIP_BUILD', 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({ type: sys.type })
|
|
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.shipSystems)).find((s) => s.id === sys.id)!.level).toBe(0) // 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)
|
|
await insertSystem(db, { type: 'HULL', level: 0, capacity: 0 })
|
|
await seedHold(db, [{ kind: 'part', name: 'part', qty: BUILD_PART_COST - 1 }])
|
|
const res = await post({ type: 'HULL', leadCrewId: c1 })
|
|
expect(res.status).toBe(422)
|
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
|
})
|
|
|
|
it('already built → 409 (existing pre-check, existing code), nothing queued', async () => {
|
|
const { db } = await freshFile()
|
|
await buildWorldFixture(db)
|
|
const [c1] = await addCrew(db, 1)
|
|
const sys = await insertSystem(db, { type: 'HULL', level: 1, capacity: 2 }) // built at base values
|
|
expect((await post({ type: sys.type, leadCrewId: c1 })).status).toBe(409)
|
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
|
})
|
|
})
|