// Component tests for the Phase 8 lobby page. The lobby API and the // gateway client are mocked at module level; the session singleton is // wired to a per-test `SessionStore`-backing IndexedDB so the page's // boot path settles on `authenticated` and constructs a real // GalaxyClient (which is then never called because the lobby API // wrappers are stubs). The tests assert the section rendering, the // inline race-name form for public games, and the invitation Accept // flow. import "fake-indexeddb/auto"; import { fireEvent, render, waitFor } from "@testing-library/svelte"; import { afterEach, beforeEach, describe, expect, test, vi, } from "vitest"; import type { IDBPDatabase } from "idb"; import { i18n } from "../src/lib/i18n/index.svelte"; import { session } from "../src/lib/session-store.svelte"; import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb"; import { IDBCache } from "../src/platform/store/idb-cache"; import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore"; vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}), })); const listMyGamesSpy = vi.fn(); const listPublicGamesSpy = vi.fn(); const listMyInvitesSpy = vi.fn(); const listMyApplicationsSpy = vi.fn(); const submitApplicationSpy = vi.fn(); const redeemInviteSpy = vi.fn(); const declineInviteSpy = vi.fn(); vi.mock("../src/api/lobby", async () => { const actual = await vi.importActual( "../src/api/lobby", ); return { ...actual, listMyGames: (...args: unknown[]) => listMyGamesSpy(...args), listPublicGames: (...args: unknown[]) => listPublicGamesSpy(...args), listMyInvites: (...args: unknown[]) => listMyInvitesSpy(...args), listMyApplications: (...args: unknown[]) => listMyApplicationsSpy(...args), submitApplication: (...args: unknown[]) => submitApplicationSpy(...args), redeemInvite: (...args: unknown[]) => redeemInviteSpy(...args), declineInvite: (...args: unknown[]) => declineInviteSpy(...args), }; }); vi.mock("../src/lib/env", () => ({ GATEWAY_BASE_URL: "http://gateway.test", GATEWAY_RESPONSE_PUBLIC_KEY: new Uint8Array(32).fill(0x55), })); vi.mock("../src/api/connect", () => ({ createEdgeGatewayClient: vi.fn(() => ({})), })); vi.mock("../src/api/galaxy-client", () => { class FakeGalaxyClient { executeCommand = vi.fn(async () => ({ resultCode: "ok", payloadBytes: new Uint8Array(), })); } return { GalaxyClient: FakeGalaxyClient }; }); vi.mock("../src/platform/core/index", () => ({ loadCore: async () => ({ signRequest: () => new Uint8Array(), verifyResponse: () => true, verifyEvent: () => true, verifyPayloadHash: () => true, }), })); let db: IDBPDatabase; let dbName: string; beforeEach(async () => { dbName = `galaxy-ui-test-${crypto.randomUUID()}`; db = await openGalaxyDB(dbName); const store = { keyStore: new WebCryptoKeyStore(db), cache: new IDBCache(db), }; session.resetForTests(); session.setStoreLoaderForTests(async () => store); await session.init(); await session.signIn("device-1"); i18n.resetForTests("en"); listMyGamesSpy.mockReset(); listPublicGamesSpy.mockReset(); listMyInvitesSpy.mockReset(); listMyApplicationsSpy.mockReset(); submitApplicationSpy.mockReset(); redeemInviteSpy.mockReset(); declineInviteSpy.mockReset(); }); afterEach(async () => { session.resetForTests(); i18n.resetForTests("en"); db.close(); await new Promise((resolve) => { const req = indexedDB.deleteDatabase(dbName); req.onsuccess = () => resolve(); req.onerror = () => resolve(); req.onblocked = () => resolve(); }); }); async function importLobbyPage(): Promise { return import("../src/routes/lobby/+page.svelte"); } const baseDate = new Date("2026-05-07T10:00:00Z"); function makeGame(id: string, name: string, status = "draft") { return { gameId: id, gameName: name, gameType: "private", status, ownerUserId: "user-1", minPlayers: 2, maxPlayers: 8, enrollmentEndsAt: baseDate, createdAt: baseDate, updatedAt: baseDate, }; } function makePublicGame(id: string, name: string) { return { gameId: id, gameName: name, gameType: "public", status: "enrollment_open", ownerUserId: "", minPlayers: 4, maxPlayers: 12, enrollmentEndsAt: baseDate, createdAt: baseDate, updatedAt: baseDate, }; } function makeInvite(id: string) { return { inviteId: id, gameId: "private-1", inviterUserId: "host", invitedUserId: "user-1", code: "", raceName: "Vegan Federation", status: "pending", createdAt: baseDate, expiresAt: baseDate, decidedAt: null, }; } function makeApplication(id: string, status: string) { return { applicationId: id, gameId: "public-1", applicantUserId: "user-1", raceName: "Vegan Federation", status, createdAt: baseDate, decidedAt: status === "pending" ? null : baseDate, }; } describe("lobby page", () => { test("renders empty states for every section when API returns no items", async () => { listMyGamesSpy.mockResolvedValue([]); listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 }); listMyInvitesSpy.mockResolvedValue([]); listMyApplicationsSpy.mockResolvedValue([]); const Page = (await importLobbyPage()).default; const ui = render(Page); await waitFor(() => { expect(ui.getByTestId("lobby-my-games-empty")).toBeInTheDocument(); expect(ui.getByTestId("lobby-invitations-empty")).toBeInTheDocument(); expect(ui.getByTestId("lobby-applications-empty")).toBeInTheDocument(); expect(ui.getByTestId("lobby-public-games-empty")).toBeInTheDocument(); }); }); test("renders my-game cards and public-game cards when items are present", async () => { listMyGamesSpy.mockResolvedValue([makeGame("private-1", "First Contact")]); listPublicGamesSpy.mockResolvedValue({ items: [makePublicGame("public-1", "Open Lobby")], page: 1, pageSize: 50, total: 1, }); listMyInvitesSpy.mockResolvedValue([]); listMyApplicationsSpy.mockResolvedValue([]); const Page = (await importLobbyPage()).default; const ui = render(Page); await waitFor(() => { expect(ui.getAllByTestId("lobby-my-game-card").length).toBe(1); expect(ui.getByText("First Contact")).toBeInTheDocument(); expect(ui.getByText("Open Lobby")).toBeInTheDocument(); }); }); test("submitting an application opens the inline form and posts race_name", async () => { listMyGamesSpy.mockResolvedValue([]); listPublicGamesSpy.mockResolvedValue({ items: [makePublicGame("public-1", "Open Lobby")], page: 1, pageSize: 50, total: 1, }); listMyInvitesSpy.mockResolvedValue([]); listMyApplicationsSpy.mockResolvedValue([]); submitApplicationSpy.mockResolvedValue(makeApplication("app-1", "pending")); const Page = (await importLobbyPage()).default; const ui = render(Page); await waitFor(() => { expect(ui.getByTestId("lobby-public-game-apply")).toBeInTheDocument(); }); await fireEvent.click(ui.getByTestId("lobby-public-game-apply")); await waitFor(() => { expect(ui.getByTestId("lobby-application-form")).toBeInTheDocument(); }); await fireEvent.input(ui.getByTestId("lobby-application-race-name"), { target: { value: "Vegan Federation" }, }); await fireEvent.click(ui.getByTestId("lobby-application-submit")); await waitFor(() => { expect(submitApplicationSpy).toHaveBeenCalledWith( expect.anything(), "public-1", "Vegan Federation", ); expect(ui.getByTestId("lobby-application-card")).toBeInTheDocument(); }); }); test("submitting an empty race name surfaces a validation error and does not call the API", async () => { listMyGamesSpy.mockResolvedValue([]); listPublicGamesSpy.mockResolvedValue({ items: [makePublicGame("public-1", "Open Lobby")], page: 1, pageSize: 50, total: 1, }); listMyInvitesSpy.mockResolvedValue([]); listMyApplicationsSpy.mockResolvedValue([]); const Page = (await importLobbyPage()).default; const ui = render(Page); await waitFor(() => expect(ui.getByTestId("lobby-public-game-apply")).toBeInTheDocument(), ); await fireEvent.click(ui.getByTestId("lobby-public-game-apply")); await fireEvent.click(ui.getByTestId("lobby-application-submit")); await waitFor(() => { expect(ui.getByTestId("lobby-application-error")).toBeInTheDocument(); expect(submitApplicationSpy).not.toHaveBeenCalled(); }); }); test("accepting an invitation calls redeemInvite and removes the card", async () => { listMyGamesSpy.mockResolvedValue([]); listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 }); listMyInvitesSpy.mockResolvedValue([makeInvite("invite-1")]); listMyApplicationsSpy.mockResolvedValue([]); redeemInviteSpy.mockResolvedValue(makeInvite("invite-1")); const Page = (await importLobbyPage()).default; const ui = render(Page); await waitFor(() => expect(ui.getByTestId("lobby-invite-accept")).toBeInTheDocument(), ); await fireEvent.click(ui.getByTestId("lobby-invite-accept")); await waitFor(() => { expect(redeemInviteSpy).toHaveBeenCalledWith( expect.anything(), "private-1", "invite-1", ); expect(ui.queryByTestId("lobby-invite-accept")).not.toBeInTheDocument(); expect(ui.getByTestId("lobby-invitations-empty")).toBeInTheDocument(); }); }); test("declining an invitation calls declineInvite and removes the card", async () => { listMyGamesSpy.mockResolvedValue([]); listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 }); listMyInvitesSpy.mockResolvedValue([makeInvite("invite-2")]); listMyApplicationsSpy.mockResolvedValue([]); declineInviteSpy.mockResolvedValue({ ...makeInvite("invite-2"), status: "declined" }); const Page = (await importLobbyPage()).default; const ui = render(Page); await waitFor(() => expect(ui.getByTestId("lobby-invite-decline")).toBeInTheDocument(), ); await fireEvent.click(ui.getByTestId("lobby-invite-decline")); await waitFor(() => { expect(declineInviteSpy).toHaveBeenCalledWith( expect.anything(), "private-1", "invite-2", ); expect(ui.queryByTestId("lobby-invite-decline")).not.toBeInTheDocument(); }); }); test("application status badges localise pending and approved states", async () => { listMyGamesSpy.mockResolvedValue([]); listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 }); listMyInvitesSpy.mockResolvedValue([]); listMyApplicationsSpy.mockResolvedValue([ makeApplication("app-1", "pending"), makeApplication("app-2", "approved"), ]); const Page = (await importLobbyPage()).default; const ui = render(Page); await waitFor(() => { const cards = ui.getAllByTestId("lobby-application-card"); expect(cards.length).toBe(2); expect(cards[0]!.querySelector(".status")?.textContent?.trim()).toBe("pending"); expect(cards[1]!.querySelector(".status")?.textContent?.trim()).toBe("approved"); }); }); });