Step 5.4: add test database harness

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-02 21:41:00 -04:00
parent 5754b7ca25
commit bcae75d822
5 changed files with 231 additions and 1 deletions

View File

@ -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). - **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. - **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`. - **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.

View File

@ -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<NewPlayer> {
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 };
}

100
apps/server/test/db.ts Normal file
View File

@ -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<void> {
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<SeededPlayer> {
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 };
}

View File

@ -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);
});
});

View File

@ -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.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.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. - [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.5 — Guest accounts
- [ ] Step 5.6 — Request validation from the route catalog - [ ] Step 5.6 — Request validation from the route catalog