114 lines
4.6 KiB
TypeScript
114 lines
4.6 KiB
TypeScript
|
|
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);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|