import { GameRecord } from "@mancala/core"; import { Valkey } from "iovalkey"; import { ValkeyCache } from "../../src/cache/Cache"; import { ValkeyLiveGameStore } from "../../src/live/LiveGameStore"; import { createValkeyClient } from "../../src/live/valkey"; import { RunningService, startValkey } from "../support/containers"; jest.setTimeout(120_000); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const record = (id: string): GameRecord => ({ id, player1: { userId: "kc:alice", name: "alice", isGuest: false }, player2: { userId: "kc:bob", name: "bob", isGuest: false }, board: [0, 0, 0, 0, 0, 0, 30, 0, 0, 0, 0, 0, 0, 18], result: "player1", endReason: "completed", startedAt: 1, endedAt: 2, moveCount: 0, peakSpectators: 0, totalSpectators: 0, moves: [], }); describe("Valkey store and cache", () => { let valkey: RunningService; let client: Valkey; beforeAll(async () => { valkey = await startValkey(); client = createValkeyClient(valkey.url); await new Promise((resolve) => client.once("ready", resolve)); }); afterAll(async () => { client?.disconnect(); await valkey?.container.stop(); }); beforeEach(() => client.flushall()); describe("ValkeyLiveGameStore", () => { it("saves, loads and removes live game snapshots", async () => { const store = new ValkeyLiveGameStore(client); await store.saveGame("g1", { id: "g1", moves: 1 }); await store.saveGame("g2", { id: "g2", moves: 2 }); await store.saveGame("g1", { id: "g1", moves: 3 }); const sortById = (games: unknown[]) => (games as { id: string }[]).sort((a, b) => a.id.localeCompare(b.id)); expect(sortById(await store.loadGames())).toEqual([{ id: "g1", moves: 3 }, { id: "g2", moves: 2 }]); await store.removeGame("g1"); expect(await store.loadGames()).toEqual([{ id: "g2", moves: 2 }]); }); it("keeps pending records separate from live games", async () => { const store = new ValkeyLiveGameStore(client); await store.saveGame("g1", { id: "g1" }); await store.savePendingRecord(record("r1")); expect(await store.loadPendingRecords()).toEqual([record("r1")]); expect(await store.loadGames()).toEqual([{ id: "g1" }]); await store.removePendingRecord("r1"); expect(await store.loadPendingRecords()).toEqual([]); }); it("skips values it cannot read", async () => { const store = new ValkeyLiveGameStore(client); await client.set("mancala:live:broken", "{not json"); await store.saveGame("g1", { id: "g1" }); expect(await store.loadGames()).toEqual([{ id: "g1" }]); }); }); describe("ValkeyCache", () => { it("returns cached values until they expire", async () => { const cache = new ValkeyCache(client); await cache.setJson("popular", [1, 2, 3], 1); expect(await cache.getJson("popular")).toEqual([1, 2, 3]); await sleep(1500); expect(await cache.getJson("popular")).toBeUndefined(); }); it("deletes entries", async () => { const cache = new ValkeyCache(client); await cache.setJson("a", 1, 60); await cache.setJson("b", 2, 60); await cache.delete("a", "b"); expect(await cache.getJson("a")).toBeUndefined(); expect(await cache.getJson("b")).toBeUndefined(); }); }); describe("when Valkey is unreachable", () => { let offline: Valkey; beforeAll(() => { // Nothing listens on port 1. No reconnect attempts, so nothing is left running after the tests. offline = createValkeyClient("redis://127.0.0.1:1", { retryStrategy: () => null, disconnectTimeout: 50 }); }); afterAll(() => offline.disconnect()); it("the cache answers with misses instead of failing", async () => { const cache = new ValkeyCache(offline); await expect(cache.setJson("a", 1, 60)).resolves.toBeUndefined(); await expect(cache.getJson("a")).resolves.toBeUndefined(); await expect(cache.delete("a")).resolves.toBeUndefined(); }); it("the store rejects at once, so callers can fall back", async () => { const store = new ValkeyLiveGameStore(offline); const started = Date.now(); await expect(store.saveGame("g1", {})).rejects.toThrow(); expect(Date.now() - started).toBeLessThan(1000); }); }); });