/
Mihaham
/
Table-Time
Обзор
Документация
Войти
/
Mihaham
/
Table-Time
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
apps/web/lib/api.test.ts
257 строк
9 KB
MihahamYT
feat: games catalog, 90%+ test coverage, MkDocs ready for GitVerse
14 июн 2026, 10:45
14 июн 2026, 10:45
03c79f9
Код
Авторство
О чём код?
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createSession, ensureGuest, formatApiError, getCatalog, getGameState, getSession, joinSession, login, register, startGame, submitAction, } from "./api"; import { isValidUUID } from "./id"; function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) { vi.stubGlobal("fetch", vi.fn((url: string, init?: RequestInit) => handler(url, init))); } describe("formatApiError", () => { it("formats strings, arrays, objects, and unknown", () => { expect(formatApiError("oops")).toBe("oops"); expect(formatApiError([{ msg: "bad field" }])).toBe("bad field"); expect(formatApiError({ code: 1 })).toBe('{"code":1}'); expect(formatApiError([{ msg: "a" }, "plain"])).toBe("a; plain"); expect(formatApiError(42)).toBe("Unknown error"); }); }); describe("fetchGuestToken errors", () => { afterEach(() => vi.unstubAllGlobals()); it("uses bearer auth header for registered users", async () => { localStorage.setItem("tabletime_token", "jwt"); localStorage.setItem("tabletime_token_type", "bearer"); mockFetch((url, init) => { expect((init?.headers as Record<string, string>).Authorization).toBe("Bearer jwt"); return new Response( JSON.stringify({ id: "s1", invite_code: "AB", status: "open", max_players: 4, participants: [], created_at: "2024-01-01", }), { status: 200 } ); }); await getSession("s1"); }); it("does not retry 401 for bearer tokens", async () => { localStorage.setItem("tabletime_token", "jwt"); localStorage.setItem("tabletime_token_type", "bearer"); let calls = 0; mockFetch(() => { calls += 1; return new Response(JSON.stringify({ detail: "Unauthorized" }), { status: 401 }); }); await expect(getSession("s1")).rejects.toThrow(); expect(calls).toBe(1); }); it("throws on failed guest auth", async () => { mockFetch((url) => { if (url.endsWith("/auth/guest")) { return new Response(JSON.stringify({ detail: "bad" }), { status: 400 }); } return new Response("{}", { status: 404 }); }); await expect(ensureGuest("X")).rejects.toThrow("bad"); }); }); describe("ensureGuest", () => { beforeEach(() => { localStorage.clear(); }); it("reuses stored token", async () => { localStorage.setItem("tabletime_token", "existing"); localStorage.setItem("tabletime_token_type", "guest"); const tokens = await ensureGuest("Player"); expect(tokens.access_token).toBe("existing"); }); it("fetches guest token when missing", async () => { mockFetch((url) => { if (url.endsWith("/auth/guest")) { return new Response(JSON.stringify({ guest_token: "g1", guest_id: "gid", token_type: "guest", expires_in: 3600 }), { status: 200, }); } return new Response("{}", { status: 404 }); }); const tokens = await ensureGuest("Alice"); expect(tokens.guest_token || tokens.access_token).toBe("g1"); expect(localStorage.getItem("tabletime_guest_name")).toBe("Alice"); }); }); describe("apiFetch retry on 401", () => { beforeEach(() => { localStorage.clear(); localStorage.setItem("tabletime_token", "stale"); localStorage.setItem("tabletime_token_type", "guest"); localStorage.setItem("tabletime_guest_name", "Bob"); }); afterEach(() => { vi.unstubAllGlobals(); }); it("refreshes guest auth and retries createSession", async () => { let sessionCalls = 0; mockFetch((url, init) => { if (url.endsWith("/auth/guest")) { return new Response(JSON.stringify({ guest_token: "fresh", token_type: "guest", expires_in: 3600 }), { status: 200, }); } if (url.endsWith("/sessions") && init?.method === "POST") { sessionCalls += 1; if (sessionCalls === 1) return new Response(JSON.stringify({ detail: "Unauthorized" }), { status: 401 }); return new Response( JSON.stringify({ id: "s1", invite_code: "ABC123", status: "open", max_players: 4, participants: [], created_at: "2024-01-01", }), { status: 201 } ); } return new Response("{}", { status: 404 }); }); const session = await createSession("Bob"); expect(session.invite_code).toBe("ABC123"); expect(sessionCalls).toBe(2); expect(localStorage.getItem("tabletime_token")).toBe("fresh"); }); }); describe("API helpers", () => { beforeEach(() => { localStorage.clear(); localStorage.setItem("tabletime_token", "tok"); localStorage.setItem("tabletime_token_type", "guest"); }); afterEach(() => { vi.unstubAllGlobals(); }); it("joinSession sends invite code", async () => { mockFetch((url, init) => { if (url.endsWith("/sessions/join")) { const body = JSON.parse(String(init?.body)); expect(body.invite_code).toBe("xyz"); return new Response(JSON.stringify({ session: { id: "s2" }, participant_id: "p1" }), { status: 200 }); } return new Response("{}", { status: 404 }); }); const result = await joinSession("xyz", "Name"); expect(result.participant_id).toBe("p1"); expect(localStorage.getItem("tabletime_participant_id")).toBe("p1"); }); it("throws formatted errors", async () => { mockFetch(() => new Response(JSON.stringify({ detail: [{ msg: "Invalid" }] }), { status: 422 })); await expect(getSession("bad")).rejects.toThrow("Invalid"); }); it("login and register store bearer token", async () => { mockFetch((url) => { if (url.includes("/auth/login") || url.includes("/auth/register")) { return new Response(JSON.stringify({ access_token: "acc", token_type: "bearer", expires_in: 3600 }), { status: 200, }); } return new Response("{}", { status: 404 }); }); await login("a@b.c", "password1"); expect(localStorage.getItem("tabletime_token_type")).toBe("bearer"); await register("a@b.c", "password1", "Nick"); expect(localStorage.getItem("tabletime_token")).toBe("acc"); }); it("covers catalog, game state, start and submit", async () => { mockFetch((url, init) => { if (url.endsWith("/games/catalog")) { return new Response(JSON.stringify({ plugins: [{ plugin_id: "dice_board", display_name: "Dice" }] }), { status: 200, }); } if (url.endsWith("/state")) { return new Response(JSON.stringify({ state: {}, view: {}, status: "active" }), { status: 200 }); } if (url.includes("/games") && init?.method === "POST" && url.includes("/actions")) { return new Response(JSON.stringify({ state: {}, view: {}, status: "active" }), { status: 200 }); } if (url.includes("/sessions/") && url.endsWith("/games")) { return new Response(JSON.stringify({ game_id: "g1" }), { status: 200 }); } if (url.includes("/sessions/s1")) { return new Response( JSON.stringify({ id: "s1", invite_code: "AB", status: "open", max_players: 4, participants: [], created_at: "2024-01-01", }), { status: 200 } ); } return new Response("{}", { status: 404 }); }); const catalog = await getCatalog(); expect(catalog.plugins[0].plugin_id).toBe("dice_board"); await getGameState("g1"); await startGame("s1", "dice_board"); await submitAction("g1", "roll_dice"); expect((fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("localhost:8100"); }); it("submitAction sends valid action_id even without crypto.randomUUID", async () => { const getRandomValues = vi.fn((arr: Uint8Array) => { for (let i = 0; i < arr.length; i++) arr[i] = (i * 17 + 3) & 0xff; return arr; }); vi.stubGlobal("crypto", { getRandomValues }); let actionId = ""; mockFetch((url, init) => { if (url.includes("/actions") && init?.method === "POST") { const body = JSON.parse(String(init.body)); actionId = body.action_id; expect(body.action_type).toBe("choose_cell"); expect(body.payload).toEqual({ row: 1, col: 2 }); return new Response(JSON.stringify({ state: {}, view: {}, status: "active" }), { status: 200 }); } return new Response("{}", { status: 404 }); }); await submitAction("g1", "choose_cell", { row: 1, col: 2 }); expect(isValidUUID(actionId)).toBe(true); expect(getRandomValues).toHaveBeenCalled(); }); });