From 85e3fb60f6221fde1fb9aaab8db02e5fb56e99df Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 00:21:09 +0300 Subject: [PATCH] feat(backend): add game history API and live game previews - GET /games/me/active and /games/me (Bearer token), /games/popular (live games, or the most spectated finished ones), and /game/:id now also returns saved records of finished games - Lists are cached in Valkey for 10 seconds, cleared when a game is saved, and empty instead of failing while the database is down - Live game previews over WebSocket: subscribers get the top 5 on subscribe and on change, at most once per second, without counting as spectators; ids are public ids - RTMT supports several connection listeners --- apps/backend/src/game/LiveGames.ts | 89 +++++++++ .../backend/src/history/GameHistoryService.ts | 46 +++++ apps/backend/src/index.ts | 13 +- apps/backend/src/rtmt/rtmt.ts | 1 + apps/backend/src/rtmt/rtmt_websocket.ts | 8 +- apps/backend/src/server/ExpressApp.ts | 94 +++++++-- apps/backend/src/server/authMiddleware.ts | 21 ++ apps/backend/tests/game/FakeRtmt.ts | 8 +- apps/backend/tests/game/LiveGames.test.ts | 92 +++++++++ apps/backend/tests/game/fakes.ts | 21 +- apps/backend/tests/server/ExpressApp.test.ts | 186 ++++++++++++++++++ docs/specs/game-history/tasks.md | 2 +- 12 files changed, 552 insertions(+), 29 deletions(-) create mode 100644 apps/backend/src/game/LiveGames.ts create mode 100644 apps/backend/src/history/GameHistoryService.ts create mode 100644 apps/backend/src/server/authMiddleware.ts create mode 100644 apps/backend/tests/game/LiveGames.test.ts create mode 100644 apps/backend/tests/server/ExpressApp.test.ts diff --git a/apps/backend/src/game/LiveGames.ts b/apps/backend/src/game/LiveGames.ts new file mode 100644 index 0000000..8884941 --- /dev/null +++ b/apps/backend/src/game/LiveGames.ts @@ -0,0 +1,89 @@ +import { + channel_on_live_games, + channel_subscribe_live_games, + channel_unsubscribe_live_games, + LiveGameSummary, +} from "@mancala/core"; +import { RTMT } from "../rtmt/rtmt"; +import { BackendGame } from "../models/Game"; +import { GameStore } from "./gamestore/GameStore"; +import { GameManager } from "./GameManager"; +import { publicUserId } from "./publicIds"; + +export const LIVE_GAMES_PUSH_COUNT = 5; +const PUSH_INTERVAL_MS = 1000; + +export function toLiveGameSummary(game: BackendGame): LiveGameSummary { + const { mancalaGame } = game; + const player = (id: string) => { + const info = game.players[id]; + const summary = { userId: publicUserId(id), isGuest: info?.isGuest ?? true }; + return info?.name === undefined ? summary : { ...summary, name: info.name }; + }; + return { + id: game.id, + player1: player(mancalaGame.player1Id), + player2: player(mancalaGame.player2Id), + board: mancalaGame.board.getStoneArray(), + turnPlayerId: publicUserId(mancalaGame.turnPlayerId), + spectatorCount: game.spectatorIds.length, + moveCount: mancalaGame.history.length, + startedAt: game.startedAt, + }; +} + +const lastActivity = (game: BackendGame) => game.moveTimes[game.moveTimes.length - 1] ?? game.startedAt; + +// Live games for the home page: most spectated first, then most recently active. Subscribers get the top games +// when they subscribe and again whenever they change, at most once per second. Subscribing does not make anyone a +// spectator. +export class LiveGames { + private readonly subscribers = new Set(); + private lastPushed = ""; + private lastPushAt = 0; + private pushTimer?: NodeJS.Timeout; + + constructor(private readonly params: { rtmt: RTMT, gameStore: GameStore, gameManager: GameManager }) { + const { rtmt, gameManager } = params; + rtmt.listenMessage(channel_subscribe_live_games, (userId: string) => this.subscribe(userId)); + rtmt.listenMessage(channel_unsubscribe_live_games, (userId: string) => this.subscribers.delete(userId)); + rtmt.listenOnClientConnectionChange((userId: string, isOnline: boolean) => { + if (!isOnline) this.subscribers.delete(userId); + }); + gameManager.onLiveGamesChanged(() => this.schedulePush()); + } + + getTop(limit: number): LiveGameSummary[] { + return this.params.gameStore.getAll() + .sort((a, b) => b.spectatorIds.length - a.spectatorIds.length || lastActivity(b) - lastActivity(a)) + .slice(0, limit) + .map(toLiveGameSummary); + } + + dispose() { + if (this.pushTimer) clearTimeout(this.pushTimer); + } + + private subscribe(userId: string) { + this.subscribers.add(userId); + this.params.rtmt.sendMessage(userId, channel_on_live_games, { games: this.getTop(LIVE_GAMES_PUSH_COUNT) }); + } + + private schedulePush() { + if (this.pushTimer) return; + const wait = Math.max(0, this.lastPushAt + PUSH_INTERVAL_MS - Date.now()); + this.pushTimer = setTimeout(() => { + this.pushTimer = undefined; + this.push(); + }, wait); + } + + private push() { + const games = this.getTop(LIVE_GAMES_PUSH_COUNT); + const serialized = JSON.stringify(games); + if (serialized === this.lastPushed) return; + this.lastPushed = serialized; + this.lastPushAt = Date.now(); + this.subscribers.forEach((userId) => this.params.rtmt.sendMessage(userId, channel_on_live_games, { games })); + } +} diff --git a/apps/backend/src/history/GameHistoryService.ts b/apps/backend/src/history/GameHistoryService.ts new file mode 100644 index 0000000..ed708a3 --- /dev/null +++ b/apps/backend/src/history/GameHistoryService.ts @@ -0,0 +1,46 @@ +import { FinishedGameSummary, GameRecord } from "@mancala/core"; +import { Cache } from "../cache/Cache"; +import { GameHistoryRepository } from "./GameHistoryRepository"; + +export const MAX_LIST_LIMIT = 20; +const CACHE_TTL_SECONDS = 10; +const POPULAR_KEY = "popular-finished"; +const recentKey = (userId: string) => `recent:${userId}`; + +// Reads saved games for the API. Lists are cached briefly and come back empty while the database is unreachable, +// so the home page still loads. +export class GameHistoryService { + constructor(private readonly repository: GameHistoryRepository, private readonly cache: Cache) { } + + get(id: string): Promise { + return this.repository.get(id); + } + + async getRecentForUser(userId: string, limit: number): Promise { + const games = await this.cached(recentKey(userId), () => this.repository.getRecentForUser(userId, MAX_LIST_LIMIT)); + return games.slice(0, limit); + } + + async getPopular(limit: number): Promise { + const games = await this.cached(POPULAR_KEY, () => this.repository.getPopular(MAX_LIST_LIMIT)); + return games.slice(0, limit); + } + + // Call when a game has been saved, so lists show it right away. + onGameSaved(record: GameRecord): Promise { + return this.cache.delete(POPULAR_KEY, recentKey(record.player1.userId), recentKey(record.player2.userId)); + } + + private async cached(key: string, load: () => Promise): Promise { + const hit = await this.cache.getJson(key); + if (hit) return hit; + try { + const games = await load(); + await this.cache.setJson(key, games, CACHE_TTL_SECONDS); + return games; + } catch (err) { + console.error("(History) Could not load games:", (err as Error).message); + return []; + } + } +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index ba328c6..36db2f5 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -11,6 +11,9 @@ import { PostgresGameHistoryRepository } from "./history/GameHistoryRepository"; import { createValkeyClient } from "./live/valkey"; import { ValkeyLiveGameStore } from "./live/LiveGameStore"; import { GameRecorder } from "./game/GameRecorder"; +import { LiveGames } from "./game/LiveGames"; +import { GameHistoryService } from "./history/GameHistoryService"; +import { ValkeyCache } from "./cache/Cache"; const DATABASE_URL = process.env.DATABASE_URL || "postgres://mancala:mancala@localhost:5433/mancala"; const VALKEY_URL = process.env.VALKEY_URL || "redis://localhost:6379"; @@ -31,19 +34,25 @@ async function main() { await waitForReady(valkey, VALKEY_STARTUP_WAIT_MS); const liveGameStore = new ValkeyLiveGameStore(valkey); - const recorder = new GameRecorder(new PostgresGameHistoryRepository(pool), liveGameStore); + const repository = new PostgresGameHistoryRepository(pool); + const recorder = new GameRecorder(repository, liveGameStore); + const history = new GameHistoryService(repository, new ValkeyCache(valkey)); + recorder.onSaved((record) => history.onGameSaved(record)); const rtmt = new RTMTWS({ tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth }); const gameStore = new GameStoreImpl(); const matchMaker = new MatchMakerImpl({ rtmt }); const gameManager = new GameManager({ rtmt, gameStore, matchMaker, liveGameStore, recorder }); + const liveGames = new LiveGames({ rtmt, gameStore, gameManager }); // Before accepting connections, so reconnecting players find their games. await gameManager.restoreLiveGames(); recorder.start(); gameManager.startTimers(); - const expressApp = new ExpressApp({ gameStore, legacyUserKeyAuth: authConfig.legacyUserKeyAuth }); + const expressApp = new ExpressApp({ + gameStore, gameManager, liveGames, history, tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth, + }); const server = new WebServer({ expressApp: expressApp.app }); rtmt.initWebSocket(server.server); } diff --git a/apps/backend/src/rtmt/rtmt.ts b/apps/backend/src/rtmt/rtmt.ts index f1845bf..adc97c0 100644 --- a/apps/backend/src/rtmt/rtmt.ts +++ b/apps/backend/src/rtmt/rtmt.ts @@ -8,5 +8,6 @@ export interface RTMT { listenMessage: (channel: string, callback: OnMessage) => any; isClientOnline(clientID: string): boolean; getUser(clientID: string): AuthUser | undefined; + // Adds a listener; every listener is called. listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void; } diff --git a/apps/backend/src/rtmt/rtmt_websocket.ts b/apps/backend/src/rtmt/rtmt_websocket.ts index df4b784..e4fd828 100644 --- a/apps/backend/src/rtmt/rtmt_websocket.ts +++ b/apps/backend/src/rtmt/rtmt_websocket.ts @@ -36,7 +36,7 @@ export class RTMTWS implements RTMT { private readonly authTimeoutMs: number; - onUserConnectionChange?: OnClientConnectionChange; + private readonly connectionChangeListeners: OnClientConnectionChange[] = []; constructor(params: { tokenVerifier: TokenVerifier, legacyUserKeyAuth: boolean, authTimeoutMs?: number }) { this.tokenVerifier = params.tokenVerifier; @@ -127,7 +127,7 @@ export class RTMTWS implements RTMT { } userSockets.add(ws); // Fired for every new socket so each device gets the current game state. - this.onUserConnectionChange?.(user.id, true); + this.connectionChangeListeners.forEach((listener) => listener(user.id, true)); } private unbindSocket(ws: ClientSocket) { @@ -138,7 +138,7 @@ export class RTMTWS implements RTMT { if (userSockets.size === 0) { this.sockets.delete(userId); this.users.delete(userId); - this.onUserConnectionChange?.(userId, false); + this.connectionChangeListeners.forEach((listener) => listener(userId, false)); } } @@ -234,6 +234,6 @@ export class RTMTWS implements RTMT { } listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange) { - this.onUserConnectionChange = onUserConnectionChange; + this.connectionChangeListeners.push(onUserConnectionChange); } } diff --git a/apps/backend/src/server/ExpressApp.ts b/apps/backend/src/server/ExpressApp.ts index bebe6a2..7b89da6 100644 --- a/apps/backend/src/server/ExpressApp.ts +++ b/apps/backend/src/server/ExpressApp.ts @@ -1,48 +1,110 @@ import cors from "cors"; import express from "express"; -import { Request, Response } from "express"; +import { NextFunction, Request, Response } from "express"; import morgan from "morgan"; import fs from "fs"; +import { FinishedGameResponse } from "@mancala/core"; import { generateKey } from "../util/key_factory"; import { GameStore } from "../game/gamestore/GameStore"; +import { GameManager } from "../game/GameManager"; +import { LiveGames, toLiveGameSummary } from "../game/LiveGames"; +import { GameHistoryService, MAX_LIST_LIMIT } from "../history/GameHistoryService"; +import { AuthUser, TokenVerifier } from "../auth"; +import { requireUser } from "./authMiddleware"; + +type AsyncHandler = (req: Request, res: Response) => Promise; + +// Express 4 does not catch rejected promises from handlers. +const handleAsync = (handler: AsyncHandler) => (req: Request, res: Response, next: NextFunction) => + handler(req, res).catch(next); + +const parseLimit = (value: unknown, fallback: number): number => { + const limit = Number.parseInt(String(value ?? ""), 10); + return Number.isNaN(limit) ? fallback : Math.min(Math.max(limit, 1), MAX_LIST_LIMIT); +}; export class ExpressApp { app: Express.Application; - gameStore: GameStore; - legacyUserKeyAuth: boolean; - constructor(params: { gameStore: GameStore, legacyUserKeyAuth: boolean }) { - this.gameStore = params.gameStore; - this.legacyUserKeyAuth = params.legacyUserKeyAuth; + private readonly params: { + gameStore: GameStore, + gameManager: GameManager, + liveGames: LiveGames, + history: GameHistoryService, + tokenVerifier: TokenVerifier, + legacyUserKeyAuth: boolean, + accessLog?: boolean, + }; + + constructor(params: ExpressApp["params"]) { + this.params = params; this.app = this.createExpressApp(); } + private createAccessLogMiddleware() { return morgan("common", { stream: fs.createWriteStream("./access.log", { flags: "a" }), }); } + public createExpressApp(): Express.Application { + const { gameStore, gameManager, liveGames, history, tokenVerifier, legacyUserKeyAuth } = this.params; const app = express(); app.use(cors()); - app.use(this.createAccessLogMiddleware()); - app.use(morgan("dev")); + if (this.params.accessLog !== false) { + app.use(this.createAccessLogMiddleware()); + app.use(morgan("dev")); + } app.get("/", (req: Request, res: Response) => { res.send("Server up and running! ci test1"); }); // Issues userKeys for legacy clients; token clients get their identity from Keycloak. - if (this.legacyUserKeyAuth) { + if (legacyUserKeyAuth) { app.get("/register/", (req: Request, res: Response) => { res.send(generateKey()); }); } - app.get("/game/:id", (req: Request, res: Response) => { - const gameId = req.params.id; - const game = this.gameStore.get(gameId); - if(game) { - res.send(game) - } else { - res.sendStatus(404) + // A live game as players receive it, or the saved record of a finished game. + app.get("/game/:id", handleAsync(async (req, res) => { + const game = gameStore.get(req.params.id); + if (game) { + res.send(gameManager.toClientGame(game)); + return; } + const record = await history.get(req.params.id); + if (record) { + const response: FinishedGameResponse = { status: "finished", record }; + res.send(response); + } else { + res.sendStatus(404); + } + })); + + // Live games if any are being played, otherwise the most spectated finished games. + app.get("/games/popular", handleAsync(async (req, res) => { + const limit = parseLimit(req.query.limit, 5); + const live = liveGames.getTop(limit); + if (live.length > 0) { + res.send({ kind: "live", games: live }); + } else { + res.send({ kind: "finished", games: await history.getPopular(limit) }); + } + })); + + app.get("/games/me/active", requireUser(tokenVerifier), (req: Request, res: Response) => { + const user = res.locals.user as AuthUser; + const game = gameStore.getGameByUser(user.id); + res.send({ game: game ? toLiveGameSummary(game) : null }); + }); + + app.get("/games/me", requireUser(tokenVerifier), handleAsync(async (req, res) => { + const user = res.locals.user as AuthUser; + res.send({ games: await history.getRecentForUser(user.id, parseLimit(req.query.limit, 3)) }); + })); + + app.use((err: Error, req: Request, res: Response, next: NextFunction) => { + console.error(`(HTTP) ${req.method} ${req.path} failed:`, err.message); + res.sendStatus(503); }); return app; diff --git a/apps/backend/src/server/authMiddleware.ts b/apps/backend/src/server/authMiddleware.ts new file mode 100644 index 0000000..8f612e6 --- /dev/null +++ b/apps/backend/src/server/authMiddleware.ts @@ -0,0 +1,21 @@ +import { NextFunction, Request, Response } from "express"; +import { AuthUser, InvalidTokenError, TokenVerifier } from "../auth"; + +// Requires an "Authorization: Bearer " header with the same Keycloak token the WebSocket uses. +// The user is available as res.locals.user. +export function requireUser(tokenVerifier: TokenVerifier) { + return async (req: Request, res: Response, next: NextFunction) => { + const match = /^Bearer (.+)$/.exec(req.headers.authorization ?? ""); + if (!match) { + res.sendStatus(401); + return; + } + try { + res.locals.user = await tokenVerifier.verify(match[1]) as AuthUser; + next(); + } catch (err) { + if (!(err instanceof InvalidTokenError)) console.error("(Auth) Token verification failed", err); + res.sendStatus(401); + } + }; +} diff --git a/apps/backend/tests/game/FakeRtmt.ts b/apps/backend/tests/game/FakeRtmt.ts index 987afc9..9ce2d4f 100644 --- a/apps/backend/tests/game/FakeRtmt.ts +++ b/apps/backend/tests/game/FakeRtmt.ts @@ -8,16 +8,16 @@ export class FakeRtmt implements RTMT { readonly sent: SentMessage[] = []; private readonly listeners = new Map(); private readonly online = new Map(); - private onConnectionChange?: OnClientConnectionChange; + private readonly connectionListeners: OnClientConnectionChange[] = []; connect(user: AuthUser) { this.online.set(user.id, user); - this.onConnectionChange?.(user.id, true); + this.connectionListeners.forEach((listener) => listener(user.id, true)); } disconnect(userId: string) { this.online.delete(userId); - this.onConnectionChange?.(userId, false); + this.connectionListeners.forEach((listener) => listener(userId, false)); } receive(userId: string, channel: string, message: Object = {}) { @@ -48,6 +48,6 @@ export class FakeRtmt implements RTMT { } listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void { - this.onConnectionChange = onUserConnectionChange; + this.connectionListeners.push(onUserConnectionChange); } } diff --git a/apps/backend/tests/game/LiveGames.test.ts b/apps/backend/tests/game/LiveGames.test.ts new file mode 100644 index 0000000..ef950a5 --- /dev/null +++ b/apps/backend/tests/game/LiveGames.test.ts @@ -0,0 +1,92 @@ +import { + channel_game_move, + channel_listen_game_events, + channel_new_game, + channel_on_live_games, + channel_subscribe_live_games, + channel_unsubscribe_live_games, +} from "@mancala/core"; +import { GameManager } from "../../src/game/GameManager"; +import { GameRecorder } from "../../src/game/GameRecorder"; +import { GameStoreImpl } from "../../src/game/gamestore/GameStoreImpl"; +import { LiveGames } from "../../src/game/LiveGames"; +import { MatchMakerImpl } from "../../src/matchmaker/MatchMakerImpl"; +import { FakeRtmt } from "./FakeRtmt"; +import { InMemoryGameHistoryRepository, InMemoryLiveGameStore } from "./fakes"; + +const ALICE = { id: "kc:alice", name: "alice", isGuest: false }; +const BOB = { id: "kc:bob", name: "bob", isGuest: false }; +const VIEWER = { id: "kc:viewer", name: "viewer", isGuest: false }; +const SPECTATOR = { id: "kc:spectator", name: "spectator", isGuest: false }; + +describe("LiveGames", () => { + let rtmt: FakeRtmt; + let gameStore: GameStoreImpl; + let liveGames: LiveGames; + + beforeEach(() => { + jest.useFakeTimers("modern"); + rtmt = new FakeRtmt(); + gameStore = new GameStoreImpl(); + const recorder = new GameRecorder(new InMemoryGameHistoryRepository(), new InMemoryLiveGameStore()); + const gameManager = new GameManager({ + rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore: new InMemoryLiveGameStore(), recorder, + }); + liveGames = new LiveGames({ rtmt, gameStore, gameManager }); + [ALICE, BOB, VIEWER, SPECTATOR].forEach((user) => rtmt.connect(user)); + }); + + afterEach(() => { + liveGames.dispose(); + jest.useRealTimers(); + }); + + const pushes = () => rtmt.messagesTo(VIEWER.id, channel_on_live_games); + + const startGame = () => { + rtmt.receive(ALICE.id, channel_new_game); + rtmt.receive(BOB.id, channel_new_game); + return gameStore.getGameByUser(ALICE.id)!; + }; + + it("sends the current games on subscribe", () => { + const game = startGame(); + rtmt.receive(VIEWER.id, channel_subscribe_live_games); + expect(pushes()).toHaveLength(1); + expect(pushes()[0].games.map((g: any) => g.id)).toEqual([game.id]); + }); + + it("pushes changes at most once per second", () => { + rtmt.receive(VIEWER.id, channel_subscribe_live_games); + const game = startGame(); + jest.advanceTimersByTime(0); + expect(pushes()).toHaveLength(2); + + // Two changes right after a push are sent together, a second later. + rtmt.receive(game.mancalaGame.turnPlayerId, channel_game_move, { index: 0 }); + rtmt.receive(SPECTATOR.id, channel_listen_game_events, game.id); + jest.advanceTimersByTime(999); + expect(pushes()).toHaveLength(2); + jest.advanceTimersByTime(1); + expect(pushes()).toHaveLength(3); + expect(pushes()[2].games[0]).toMatchObject({ moveCount: 1, spectatorCount: 1 }); + }); + + it("does not count subscribers as spectators", () => { + const game = startGame(); + rtmt.receive(VIEWER.id, channel_subscribe_live_games); + expect(game.spectatorIds).toEqual([]); + expect(pushes()[0].games[0].spectatorCount).toBe(0); + }); + + it("stops pushing to users who unsubscribe or go offline", () => { + rtmt.receive(VIEWER.id, channel_subscribe_live_games); + rtmt.receive(SPECTATOR.id, channel_subscribe_live_games); + rtmt.receive(VIEWER.id, channel_unsubscribe_live_games); + rtmt.disconnect(SPECTATOR.id); + const before = rtmt.sent.length; + startGame(); + jest.advanceTimersByTime(1000); + expect(rtmt.sent.slice(before).filter((m) => m.channel === channel_on_live_games)).toEqual([]); + }); +}); diff --git a/apps/backend/tests/game/fakes.ts b/apps/backend/tests/game/fakes.ts index b94efc9..22bc272 100644 --- a/apps/backend/tests/game/fakes.ts +++ b/apps/backend/tests/game/fakes.ts @@ -1,6 +1,7 @@ import { FinishedGameSummary, GameRecord } from "@mancala/core"; import { GameHistoryRepository } from "../../src/history/GameHistoryRepository"; import { LiveGameStore } from "../../src/live/LiveGameStore"; +import { Cache } from "../../src/cache/Cache"; export class InMemoryLiveGameStore implements LiveGameStore { readonly games = new Map(); @@ -38,6 +39,9 @@ export class InMemoryLiveGameStore implements LiveGameStore { } } +// Lists leave out the moves, like the Postgres repository. +const toSummary = ({ moves, totalSpectators, ...summary }: GameRecord): FinishedGameSummary => summary; + export class InMemoryGameHistoryRepository implements GameHistoryRepository { readonly records = new Map(); failing = false; @@ -53,13 +57,26 @@ export class InMemoryGameHistoryRepository implements GameHistoryRepository { async getRecentForUser(userId: string, limit: number): Promise { return [...this.records.values()] .filter((r) => r.player1.userId === userId || r.player2.userId === userId) - .sort((a, b) => b.endedAt - a.endedAt).slice(0, limit); + .sort((a, b) => b.endedAt - a.endedAt).slice(0, limit).map(toSummary); } async getPopular(limit: number): Promise { return [...this.records.values()] - .sort((a, b) => b.peakSpectators - a.peakSpectators || b.endedAt - a.endedAt).slice(0, limit); + .sort((a, b) => b.peakSpectators - a.peakSpectators || b.endedAt - a.endedAt).slice(0, limit).map(toSummary); } } // Lets fire-and-forget promises (saving to Valkey or the database) settle. export const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); + +export class InMemoryCache implements Cache { + readonly values = new Map(); + async getJson(key: string) { + return this.values.get(key) as T | undefined; + } + async setJson(key: string, value: unknown) { + this.values.set(key, JSON.parse(JSON.stringify(value))); + } + async delete(...keys: string[]) { + keys.forEach((key) => this.values.delete(key)); + } +} diff --git a/apps/backend/tests/server/ExpressApp.test.ts b/apps/backend/tests/server/ExpressApp.test.ts new file mode 100644 index 0000000..b9f94a5 --- /dev/null +++ b/apps/backend/tests/server/ExpressApp.test.ts @@ -0,0 +1,186 @@ +import * as http from "http"; +import { AddressInfo } from "net"; +import { + channel_game_move, + channel_leave_game, + channel_listen_game_events, + channel_new_game, + GameRecord, +} from "@mancala/core"; +import { AuthUser, InvalidTokenError, TokenVerifier } from "../../src/auth"; +import { GameManager } from "../../src/game/GameManager"; +import { GameRecorder } from "../../src/game/GameRecorder"; +import { GameStoreImpl } from "../../src/game/gamestore/GameStoreImpl"; +import { LiveGames } from "../../src/game/LiveGames"; +import { publicUserId } from "../../src/game/publicIds"; +import { GameHistoryService } from "../../src/history/GameHistoryService"; +import { MatchMakerImpl } from "../../src/matchmaker/MatchMakerImpl"; +import { ExpressApp } from "../../src/server/ExpressApp"; +import { FakeRtmt } from "../game/FakeRtmt"; +import { flushPromises, InMemoryCache, InMemoryGameHistoryRepository, InMemoryLiveGameStore } from "../game/fakes"; + +const ALICE = { id: "kc:alice", name: "alice", isGuest: false }; +const BOB = { id: "kc:bob", name: "bob", isGuest: false }; +const CAROL = { id: "kc:carol", name: "carol", isGuest: false }; +const DAVE = { id: "kc:dave", name: "dave", isGuest: false }; +const LEGACY = { id: "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11", isGuest: true }; + +// Accepts tokens of the form "valid:". +const verifier: TokenVerifier = { + async verify(token: string): Promise { + if (!token.startsWith("valid:")) throw new InvalidTokenError("bad token"); + return { id: token.slice("valid:".length), isGuest: false }; + }, +}; + +describe("HTTP API", () => { + let rtmt: FakeRtmt; + let gameStore: GameStoreImpl; + let repository: InMemoryGameHistoryRepository; + let gameManager: GameManager; + let liveGames: LiveGames; + let server: http.Server; + let baseUrl: string; + + beforeEach(async () => { + rtmt = new FakeRtmt(); + gameStore = new GameStoreImpl(); + repository = new InMemoryGameHistoryRepository(); + const liveGameStore = new InMemoryLiveGameStore(); + const recorder = new GameRecorder(repository, liveGameStore); + const history = new GameHistoryService(repository, new InMemoryCache()); + recorder.onSaved((record) => history.onGameSaved(record)); + gameManager = new GameManager({ rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder }); + liveGames = new LiveGames({ rtmt, gameStore, gameManager }); + const app = new ExpressApp({ + gameStore, gameManager, liveGames, history, tokenVerifier: verifier, legacyUserKeyAuth: true, accessLog: false, + }).app as http.RequestListener; + server = http.createServer(app); + await new Promise((resolve) => server.listen(0, resolve)); + baseUrl = `http://localhost:${(server.address() as AddressInfo).port}`; + [ALICE, BOB, CAROL, DAVE].forEach((user) => rtmt.connect(user)); + }); + + afterEach(async () => { + liveGames.dispose(); + await new Promise((resolve) => server.close(resolve)); + }); + + const get = async (path: string, userId?: string) => { + const response = await fetch(baseUrl + path, userId ? { headers: { Authorization: `Bearer valid:${userId}` } } : {}); + const body = response.headers.get("content-type")?.includes("json") ? await response.json() : undefined; + return { status: response.status, body }; + }; + + const startGame = (first: { id: string } = ALICE, second: { id: string } = BOB) => { + rtmt.receive(first.id, channel_new_game); + rtmt.receive(second.id, channel_new_game); + return gameStore.getGameByUser(first.id)!; + }; + + const finishGame = async (first: { id: string } = ALICE, second: { id: string } = BOB) => { + const game = startGame(first, second); + rtmt.receive(first.id, channel_leave_game); + await flushPromises(); + return repository.records.get(game.id)!; + }; + + describe("GET /game/:id", () => { + it("returns a live game as players receive it", async () => { + const game = startGame(); + rtmt.receive(CAROL.id, channel_listen_game_events, game.id); + const { status, body } = await get(`/game/${game.id}`); + expect(status).toBe(200); + expect(body.id).toBe(game.id); + expect(body.mancalaGame.player1Id).toBe(game.mancalaGame.player1Id); + expect(body.spectatorCount).toBe(1); + expect(body).not.toHaveProperty("spectatorIds"); + }); + + it("returns the saved record of a finished game", async () => { + const record = await finishGame(); + const { status, body } = await get(`/game/${record.id}`); + expect(status).toBe(200); + expect(body).toEqual({ status: "finished", record }); + }); + + it("answers 404 for unknown games and 503 when the database is down", async () => { + expect((await get("/game/unknown")).status).toBe(404); + repository.failing = true; + expect((await get("/game/unknown")).status).toBe(503); + }); + }); + + describe("GET /games/popular", () => { + it("lists live games, most spectated first, with public ids", async () => { + rtmt.connect(LEGACY); + const quiet = startGame(ALICE, LEGACY); + const watched = startGame(BOB, CAROL); + rtmt.receive(DAVE.id, channel_listen_game_events, watched.id); + + const { body } = await get("/games/popular?limit=5"); + expect(body.kind).toBe("live"); + expect(body.games.map((g: any) => g.id)).toEqual([watched.id, quiet.id]); + expect(body.games[0].spectatorCount).toBe(1); + expect(JSON.stringify(body)).not.toContain(LEGACY.id); + expect(JSON.stringify(body)).toContain(publicUserId(LEGACY.id)); + }); + + it("lists the most spectated finished games when nothing is live", async () => { + const watched = startGame(); + rtmt.receive(CAROL.id, channel_listen_game_events, watched.id); + rtmt.receive(ALICE.id, channel_leave_game); + const quiet = await finishGame(CAROL, DAVE); + await flushPromises(); + + const { body } = await get("/games/popular"); + expect(body.kind).toBe("finished"); + expect(body.games.map((g: any) => g.id)).toEqual([watched.id, quiet.id]); + expect(body.games[0]).not.toHaveProperty("moves"); + }); + }); + + describe("GET /games/me/active", () => { + it("requires a valid token", async () => { + expect((await get("/games/me/active")).status).toBe(401); + const response = await fetch(baseUrl + "/games/me/active", { headers: { Authorization: "Bearer forged" } }); + expect(response.status).toBe(401); + }); + + it("returns the user's live game, or null", async () => { + expect((await get("/games/me/active", ALICE.id)).body).toEqual({ game: null }); + const game = startGame(); + const { body } = await get("/games/me/active", ALICE.id); + expect(body.game).toMatchObject({ id: game.id, turnPlayerId: game.mancalaGame.turnPlayerId, moveCount: 0 }); + expect(body.game.player1.name).toBeDefined(); + }); + }); + + describe("GET /games/me", () => { + it("requires a valid token", async () => { + expect((await get("/games/me")).status).toBe(401); + }); + + it("returns the user's latest finished games, and shows a new one right away despite the cache", async () => { + const first = await finishGame(ALICE, BOB); + await finishGame(CAROL, DAVE); + expect((await get("/games/me", ALICE.id)).body.games.map((g: GameRecord) => g.id)).toEqual([first.id]); + + const second = await finishGame(BOB, ALICE); + await flushPromises(); + const { body } = await get("/games/me?limit=1", ALICE.id); + expect(body.games.map((g: GameRecord) => g.id)).toEqual([second.id]); + }); + + it("returns an empty list while the database is down", async () => { + repository.failing = true; + expect((await get("/games/me", ALICE.id)).body).toEqual({ games: [] }); + }); + }); + + it("moves keep working with the API in place", async () => { + const game = startGame(); + rtmt.receive(game.mancalaGame.turnPlayerId, channel_game_move, { index: 0 }); + expect((await get(`/game/${game.id}`)).body.mancalaGame.history).toHaveLength(1); + }); +}); diff --git a/docs/specs/game-history/tasks.md b/docs/specs/game-history/tasks.md index 7385b8c..7ded402 100644 --- a/docs/specs/game-history/tasks.md +++ b/docs/specs/game-history/tasks.md @@ -15,7 +15,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 6. **Game lifecycle**: start and move times, spectator counters, outcomes (completed, left, timeout, inactive), timeout watcher, `offlineSince`, rejecting a new game while playing, saving with retry, restore on startup; unit tests. -- [ ] 7. **API**: public ids, Bearer auth middleware, `/games/me/active`, `/games/me`, `/games/popular`, +- [x] 7. **API**: public ids, Bearer auth middleware, `/games/me/active`, `/games/me`, `/games/popular`, `/game/:id` for finished games, live games channel with throttled push; tests. - [ ] 8. **Board size**: `--board-unit` in `BoardView`, `PitView`, `StoreView`, `StoneView`; `width` prop. - [ ] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby