mancala/apps/backend/tests/live/ValkeyStore.test.ts

114 lines
4.6 KiB
TypeScript
Raw Normal View History

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);
});
});
});