187 lines
8.4 KiB
TypeScript
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);
|
||
|
|
});
|
||
|
|
});
|