Step 5.2: add server time endpoint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-02 21:35:52 -04:00
parent af849feea7
commit c809e10d7e
6 changed files with 56 additions and 3 deletions

View File

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

View File

@ -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 Fastify, { type FastifyInstance } from "fastify";
import { type Clock, systemClock } from "./clock";
import type { ServerConfig } from "./config"; import type { ServerConfig } from "./config";
export interface AppDeps {
clock?: Clock;
}
/** Builds the Fastify app without listening, so tests can start it on any port. */ /** 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({ const app = Fastify({
// Structured JSON logs (pino); silenced in tests unless LOG_LEVEL says otherwise. // Structured JSON logs (pino); silenced in tests unless LOG_LEVEL says otherwise.
logger: { level: config.logLevel }, 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). // Under /api so it is reachable through the same-domain proxy (decision 0.5.20).
app.get("/api/health", async () => ({ status: "ok" })); app.get("/api/health", async () => ({ status: "ok" }));
app.get(routes.getServerTime.path, async (): Promise<ServerTime> => {
const realTime = clock();
return { realTime, gameTime: realToGame(realTime, content.config) };
});
return app; return app;
} }

6
apps/server/src/clock.ts Normal file
View File

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

View File

@ -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"; export { loadConfig, type ServerConfig } from "./config";

View File

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

View File

@ -46,7 +46,7 @@
## Phase 5 — Server Foundation ## 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. - [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.3 — Database schema and migrations
- [ ] Step 5.4 — Test database harness - [ ] Step 5.4 — Test database harness
- [ ] Step 5.5 — Guest accounts - [ ] Step 5.5 — Guest accounts