From bcae75d8227977a71a93a89a5e715c11da78348d Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 2 Oct 2026 21:41:00 -0400 Subject: [PATCH] Step 5.4: add test database harness Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 8 +++ apps/server/src/game/new-player.ts | 62 ++++++++++++++++++ apps/server/test/db.ts | 100 +++++++++++++++++++++++++++++ apps/server/test/harness.test.ts | 60 +++++++++++++++++ memory-bank/progress.md | 2 +- 5 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/game/new-player.ts create mode 100644 apps/server/test/db.ts create mode 100644 apps/server/test/harness.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index b4f5672..b9f764d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -225,3 +225,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Migrations are generated SQL** (`drizzle-kit generate`) committed in `apps/server/drizzle/`, which Prettier ignores. They are applied with the drizzle-orm migrator (`db:migrate`, or `runMigrations(url)` in tests). - **Server test files run one at a time** (`apps/server/vitest.config.ts`) because integration tests share one test database. - **CI runs a `postgres:18` service container** and points `TEST_DATABASE_URL` at it; locally the default is the compose database `deadhead_test`. + +## Step 5.4 + +- **The harness truncates every table in the `public` schema before each test**, rather than wrapping tests in rolled-back transactions. It is simpler, and it works for code that opens its own transactions. Because test files run one at a time, this is safe. +- **`createPlayer` is real game code** (`src/game/new-player.ts`), used both by the harness and, from Step 5.5, by guest sign-up. A new player gets starting credits, the starting-discovered locations, the avatar, and tutorial mode with the loaner at Earth. +- **The avatar is named "Captain"** and the company "Deadhead Freight" for now; there is no naming step in the base game. Hired pilots get generated names (Step 6.8). +- **During the tutorial the avatar is unassigned and stores its location** (decision 0.5.27). The tutorial dispatch code keeps it equal to the loaner's location. +- **Seed states:** `tutorial` (a brand-new player) and `owner` (tutorial over, one Courier at Earth crewed by the avatar, an Earth bay). Later steps add states as their tests need them. diff --git a/apps/server/src/game/new-player.ts b/apps/server/src/game/new-player.ts new file mode 100644 index 0000000..b90c56f --- /dev/null +++ b/apps/server/src/game/new-player.ts @@ -0,0 +1,62 @@ +import { randomUUID } from "node:crypto"; +import { content } from "@deadhead/content"; +import type { RealTime, Seed } from "@deadhead/sim"; +import type { Db } from "../db/client"; +import { companies, discoveredLocations, pilots, players } from "../db/schema"; + +export const AVATAR_NAME = "Captain"; +export const COMPANY_NAME = "Deadhead Freight"; + +export interface NewPlayer { + playerId: string; + companyId: string; + avatarId: string; +} + +/** + * Creates a fresh player in tutorial mode: starting credits, every location + * that starts discovered, the avatar (level 0, unassigned), and the loaner at + * Earth. The avatar's position comes from the loaner while the tutorial runs. + */ +export async function createPlayer( + db: Db, + args: { worldSeed: Seed; now: RealTime; playerId?: string }, +): Promise { + const { worldSeed, now } = args; + const playerId = args.playerId ?? randomUUID(); + const companyId = randomUUID(); + const avatarId = randomUUID(); + + await db.transaction(async (tx) => { + await tx.insert(players).values({ + id: playerId, + worldSeed, + tutorialActive: true, + loanerLocationId: "earth", + createdAt: now, + }); + await tx.insert(companies).values({ + id: companyId, + playerId, + name: COMPANY_NAME, + credits: content.config.startingCredits, + }); + await tx + .insert(discoveredLocations) + .values( + content.locations + .filter((l) => l.startsDiscovered) + .map((l) => ({ playerId, locationId: l.id, discoveredAt: now })), + ); + await tx.insert(pilots).values({ + id: avatarId, + playerId, + name: AVATAR_NAME, + isAvatar: true, + locationId: "earth", + createdAt: now, + }); + }); + + return { playerId, companyId, avatarId }; +} diff --git a/apps/server/test/db.ts b/apps/server/test/db.ts new file mode 100644 index 0000000..76aeb67 --- /dev/null +++ b/apps/server/test/db.ts @@ -0,0 +1,100 @@ +import { randomUUID } from "node:crypto"; +import { content } from "@deadhead/content"; +import { eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach } from "vitest"; +import { createDatabase, type Database, type Db } from "../src/db/client"; +import { runMigrations } from "../src/db/migrate"; +import { bays, companies, pilots, players, ships } from "../src/db/schema"; +import { createPlayer, type NewPlayer } from "../src/game/new-player"; +import { testConfig } from "./helpers"; + +export const TEST_NOW = Date.parse("2100-01-01T00:00:00Z"); + +/** + * Connects a test file to the test database: migrates once, empties every + * table before each test, and closes the pool at the end. Use `database()` + * inside tests. + */ +export function useTestDatabase(): () => Database { + let database: Database | undefined; + const url = testConfig().databaseUrl; + + beforeAll(async () => { + await runMigrations(url); + database = createDatabase(url); + }); + beforeEach(async () => { + await resetDatabase(database!.db); + }); + afterAll(async () => { + await database?.close(); + }); + + return () => { + if (!database) throw new Error("useTestDatabase: database not ready (use it inside tests)"); + return database; + }; +} + +/** Empties every table in the public schema. */ +export async function resetDatabase(db: Db): Promise { + const result = await db.execute<{ tablename: string }>( + sql`select tablename from pg_tables where schemaname = 'public'`, + ); + const names = result.rows.map((r) => `"${r.tablename}"`); + if (names.length > 0) await db.execute(sql.raw(`truncate ${names.join(", ")} cascade`)); +} + +export type SeedState = "tutorial" | "owner"; + +export interface SeededPlayer extends NewPlayer { + /** The Courier owned in the "owner" state. */ + shipId?: string; +} + +/** + * Creates a player in a named state: + * - `tutorial`: brand new player (what guest sign-up creates). + * - `owner`: tutorial over, owns one Courier at Earth crewed by the avatar, has an Earth bay. + */ +export async function seedPlayer( + db: Db, + state: SeedState, + options: { worldSeed?: number; credits?: number } = {}, +): Promise { + const player = await createPlayer(db, { worldSeed: options.worldSeed ?? 12345, now: TEST_NOW }); + if (options.credits !== undefined) { + await db + .update(companies) + .set({ credits: options.credits }) + .where(eq(companies.playerId, player.playerId)); + } + if (state === "tutorial") return player; + + const shipId = randomUUID(); + await db.transaction(async (tx) => { + await tx + .update(players) + .set({ tutorialActive: false, loanerLocationId: null }) + .where(eq(players.id, player.playerId)); + await tx.insert(ships).values({ + id: shipId, + playerId: player.playerId, + modelId: content.shipModels[0]!.id, + name: "Test Courier", + locationId: "earth", + createdAt: TEST_NOW, + }); + await tx + .update(pilots) + .set({ assignedShipId: shipId, locationId: null }) + .where(eq(pilots.id, player.avatarId)); + await tx.insert(bays).values({ + id: randomUUID(), + playerId: player.playerId, + locationId: "earth", + createdAt: TEST_NOW, + }); + }); + return { ...player, shipId }; +} diff --git a/apps/server/test/harness.test.ts b/apps/server/test/harness.test.ts new file mode 100644 index 0000000..07b79ce --- /dev/null +++ b/apps/server/test/harness.test.ts @@ -0,0 +1,60 @@ +import { content } from "@deadhead/content"; +import { eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { bays, companies, discoveredLocations, pilots, players, ships } from "../src/db/schema"; +import { seedPlayer, useTestDatabase } from "./db"; + +const database = useTestDatabase(); + +describe("test database harness", () => { + it("isolates tests: first test sees only its own player", async () => { + const { db } = database(); + await seedPlayer(db, "tutorial"); + expect(await db.select().from(players)).toHaveLength(1); + }); + + it("isolates tests: second test also sees only its own player", async () => { + const { db } = database(); + await seedPlayer(db, "tutorial"); + expect(await db.select().from(players)).toHaveLength(1); + }); + + it("seeds a new tutorial player", async () => { + const { db } = database(); + const { playerId, avatarId } = await seedPlayer(db, "tutorial"); + const [player] = await db.select().from(players).where(eq(players.id, playerId)); + const [company] = await db.select().from(companies).where(eq(companies.playerId, playerId)); + const discovered = await db + .select() + .from(discoveredLocations) + .where(eq(discoveredLocations.playerId, playerId)); + const [avatar] = await db.select().from(pilots).where(eq(pilots.id, avatarId)); + + expect(player).toMatchObject({ tutorialActive: true, loanerLocationId: "earth" }); + expect(company!.credits).toBe(content.config.startingCredits); + expect(discovered.map((d) => d.locationId).sort()).toEqual(["earth", "moon"]); + expect(avatar).toMatchObject({ isAvatar: true, xp: 0, assignedShipId: null }); + expect(await db.select().from(ships)).toHaveLength(0); + }); + + it("seeds an owner with one crewed Courier and an Earth bay", async () => { + const { db } = database(); + const { playerId, avatarId, shipId } = await seedPlayer(db, "owner", { credits: 777 }); + const [player] = await db.select().from(players).where(eq(players.id, playerId)); + const [avatar] = await db.select().from(pilots).where(eq(pilots.id, avatarId)); + + expect(player!.tutorialActive).toBe(false); + expect(await db.select().from(ships)).toEqual([ + expect.objectContaining({ + id: shipId, + modelId: "courier", + locationId: "earth", + status: "idle", + }), + ]); + expect(avatar!.assignedShipId).toBe(shipId); + expect((await db.select().from(bays)).map((b) => b.locationId)).toEqual(["earth"]); + const [company] = await db.select().from(companies); + expect(company!.credits).toBe(777); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 4f259e3..fe66c0c 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -48,6 +48,6 @@ - [x] **Step 5.1 — Fastify skeleton.** `apps/server`: Zod-validated env config (`src/config.ts`), `buildApp` with pino JSON logging and `GET /api/health` (`src/app.ts`), entry point `src/main.ts` (loads repo-root `.env` if present). `pnpm --filter @deadhead/server dev|start` run it with tsx. Integration test starts the app on an ephemeral port and calls health over HTTP. - [x] **Step 5.2 — Server time endpoint.** `GET /api/time` returns `{ realTime, gameTime }` from an injectable `Clock` (`src/clock.ts`, passed via `buildApp(config, { clock })`). - [x] **Step 5.3 — Database schema and migrations.** Drizzle schema (`apps/server/src/db/schema.ts`, copied into `architecture.md`), first migration `drizzle/0000_init.sql`, `db:generate` / `db:migrate` scripts. Migration test runs against an emptied test database twice. CI now has a Postgres 18 service; server tests run one file at a time. -- [ ] Step 5.4 — Test database harness +- [x] **Step 5.4 — Test database harness.** `apps/server/test/db.ts`: `useTestDatabase()` (migrate once, truncate all tables before each test) and `seedPlayer(db, "tutorial" | "owner")`. New-player creation lives in `src/game/new-player.ts` (`createPlayer`), shared with guest sign-up. - [ ] Step 5.5 — Guest accounts - [ ] Step 5.6 — Request validation from the route catalog