mancala/apps/backend/tests/server/ExpressApp.test.ts
Halit Aksoy 85e3fb60f6 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
2026-10-16 00:21:09 +03:00

187 lines
8.4 KiB
TypeScript

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:<user id>".
const verifier: TokenVerifier = {
async verify(token: string): Promise<AuthUser> {
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<void>((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);
});
});