mancala/apps/backend/tests/game/LiveGames.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

93 lines
3.7 KiB
TypeScript

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([]);
});
});