diff --git a/DECISIONS.md b/DECISIONS.md index 6ab80f7..150b42d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -207,3 +207,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Config** comes from env vars validated with Zod in `loadConfig`, which reports every problem at once. `NODE_ENV`, `PORT` and `LOG_LEVEL` have defaults (development, 3000, info). The entry point loads the repo-root `.env` with `process.loadEnvFile` when the file exists; deployments set real env vars. - **The health endpoint is `GET /api/health`**, under `/api` so it is reachable through the same-domain proxy (decision 0.5.20). It is infrastructure, so it is not in the route catalog. - **`buildApp(config)` does not listen,** so tests start it on port 0. Logs are pino JSON; tests use `LOG_LEVEL=silent`. + +## Step 5.2 + +- **The injectable clock arrives now, not in Step 7.2.** `buildApp(config, { clock })` takes a `Clock` (`() => RealTime`) and defaults to `Date.now`. Every endpoint will read "now" through it (decision 0.5.18). Step 7.2 still adds the test-only clock-offset endpoint. +- **Game time uses the default `content.config`**, via sim `realToGame`. diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index dd5addb..7b9ff39 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,8 +1,17 @@ +import { content } from "@deadhead/content"; +import { routes, type ServerTime } from "@deadhead/schema"; +import { realToGame } from "@deadhead/sim"; import Fastify, { type FastifyInstance } from "fastify"; +import { type Clock, systemClock } from "./clock"; import type { ServerConfig } from "./config"; +export interface AppDeps { + clock?: Clock; +} + /** Builds the Fastify app without listening, so tests can start it on any port. */ -export function buildApp(config: ServerConfig): FastifyInstance { +export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInstance { + const clock = deps.clock ?? systemClock; const app = Fastify({ // Structured JSON logs (pino); silenced in tests unless LOG_LEVEL says otherwise. logger: { level: config.logLevel }, @@ -11,5 +20,10 @@ export function buildApp(config: ServerConfig): FastifyInstance { // Under /api so it is reachable through the same-domain proxy (decision 0.5.20). app.get("/api/health", async () => ({ status: "ok" })); + app.get(routes.getServerTime.path, async (): Promise => { + const realTime = clock(); + return { realTime, gameTime: realToGame(realTime, content.config) }; + }); + return app; } diff --git a/apps/server/src/clock.ts b/apps/server/src/clock.ts new file mode 100644 index 0000000..40d3df8 --- /dev/null +++ b/apps/server/src/clock.ts @@ -0,0 +1,6 @@ +import type { RealTime } from "@deadhead/sim"; + +/** The server's only source of "now" (decision 0.5.18), so tests can control time. */ +export type Clock = () => RealTime; + +export const systemClock: Clock = () => Date.now(); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 4424b84..b5971c7 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,2 +1,3 @@ -export { buildApp } from "./app"; +export { buildApp, type AppDeps } from "./app"; +export { systemClock, type Clock } from "./clock"; export { loadConfig, type ServerConfig } from "./config"; diff --git a/apps/server/test/time.test.ts b/apps/server/test/time.test.ts new file mode 100644 index 0000000..fa4dc8d --- /dev/null +++ b/apps/server/test/time.test.ts @@ -0,0 +1,27 @@ +import { content } from "@deadhead/content"; +import { serverTimeSchema } from "@deadhead/schema"; +import { realToGame } from "@deadhead/sim"; +import { afterAll, describe, expect, it } from "vitest"; +import { buildApp } from "../src/app"; +import { testConfig } from "./helpers"; + +describe("server time endpoint", () => { + const app = buildApp(testConfig()); + afterAll(() => app.close()); + + it("returns the current real time and the matching game time", async () => { + const response = await app.inject({ method: "GET", url: "/api/time" }); + expect(response.statusCode).toBe(200); + const body = serverTimeSchema.parse(response.json()); + expect(Math.abs(body.realTime - Date.now())).toBeLessThan(2000); + expect(body.gameTime).toBe(realToGame(body.realTime, content.config)); + }); + + it("reads time from the injected clock", async () => { + const fixed = Date.parse("2100-06-01T12:00:00Z"); + const fixedApp = buildApp(testConfig(), { clock: () => fixed }); + const body = (await fixedApp.inject({ method: "GET", url: "/api/time" })).json(); + expect(body).toEqual({ realTime: fixed, gameTime: realToGame(fixed, content.config) }); + await fixedApp.close(); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index a8a6493..89884ff 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -46,7 +46,7 @@ ## Phase 5 — Server Foundation - [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. -- [ ] Step 5.2 — Server time endpoint +- [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 })`). - [ ] Step 5.3 — Database schema and migrations - [ ] Step 5.4 — Test database harness - [ ] Step 5.5 — Guest accounts