GET /games/popular?kind=finished returns the most watched finished games even when games are being played, so the web app can show live and popular games together.
211 lines
9.5 KiB
TypeScript
211 lines
9.5 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.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<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");
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|