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.getUnsaved()); 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"); }); it("lists finished games while games are live when asked for finished games", async () => { const finished = await finishGame(CAROL, DAVE); await flushPromises(); startGame(); expect((await get("/games/popular")).body.kind).toBe("live"); const { body } = await get("/games/popular?kind=finished"); expect(body.kind).toBe("finished"); expect(body.games.map((g: any) => g.id)).toEqual([finished.id]); }); }); 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("includes a game that ended but is not saved yet", async () => { repository.failing = true; const game = startGame(); rtmt.receive(ALICE.id, channel_leave_game); await flushPromises(); expect((await get("/games/me", ALICE.id)).body.games.map((g: GameRecord) => g.id)).toEqual([game.id]); expect((await get("/games/popular")).body.games.map((g: GameRecord) => g.id)).toEqual([game.id]); const { body } = await get(`/game/${game.id}`); expect(body.status).toBe("finished"); expect(body.record.moves).toEqual([]); }); }); 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); }); });