// Unit tests for the typed lobby.ts wrappers. They invoke the // wrappers against a minimal stub of `GalaxyClient.executeCommand` // that captures the message type and FlatBuffers request payload, // then returns a forged FlatBuffers response payload built with the // generated TS bindings. No network, no signing — the test confirms // the encoder/decoder shape matches the gateway contract and that // non-`ok` result codes are surfaced as a `LobbyError`. import { Builder, ByteBuffer } from "flatbuffers"; import { describe, expect, test, vi } from "vitest"; import { LobbyError, createGame, declineInvite, listMyApplications, listMyGames, listMyInvites, listPublicGames, redeemInvite, submitApplication, } from "../src/api/lobby"; import { ApplicationSubmitResponse, ApplicationSummary, ErrorBody, ErrorResponse, GameCreateResponse, GameSummary, InviteDeclineResponse, InviteRedeemResponse, InviteSummary, MyApplicationsListResponse, MyGamesListResponse, MyInvitesListResponse, PublicGamesListResponse, } from "../src/proto/galaxy/fbs/lobby"; import type { GalaxyClient } from "../src/api/galaxy-client"; import { GameCreateRequest, PublicGamesListRequest, ApplicationSubmitRequest, InviteRedeemRequest, InviteDeclineRequest, } from "../src/proto/galaxy/fbs/lobby"; interface Captured { messageType: string; payload: Uint8Array; } function makeStub( respondWith: (c: Captured) => { resultCode?: string; payloadBytes: Uint8Array }, ): { client: GalaxyClient; captured: Captured[]; } { const captured: Captured[] = []; const stub = { executeCommand: vi.fn(async (messageType: string, payload: Uint8Array) => { const c = { messageType, payload }; captured.push(c); const result = respondWith(c); return { resultCode: result.resultCode ?? "ok", payloadBytes: result.payloadBytes, }; }), } as unknown as GalaxyClient; return { client: stub, captured }; } function encodeGameSummary(builder: Builder, currentTurn: number = 0): number { const gameId = builder.createString("g-1"); const gameName = builder.createString("Test Game"); const gameType = builder.createString("private"); const status = builder.createString("draft"); const ownerUserId = builder.createString("user-1"); GameSummary.startGameSummary(builder); GameSummary.addGameId(builder, gameId); GameSummary.addGameName(builder, gameName); GameSummary.addGameType(builder, gameType); GameSummary.addStatus(builder, status); GameSummary.addOwnerUserId(builder, ownerUserId); GameSummary.addMinPlayers(builder, 2); GameSummary.addMaxPlayers(builder, 8); GameSummary.addEnrollmentEndsAtMs(builder, 1_780_000_000_000n); GameSummary.addCreatedAtMs(builder, 1_770_000_000_000n); GameSummary.addUpdatedAtMs(builder, 1_770_000_000_000n); GameSummary.addCurrentTurn(builder, currentTurn); return GameSummary.endGameSummary(builder); } function encodeApplicationSummary(builder: Builder, status: string): number { const applicationId = builder.createString("app-1"); const gameId = builder.createString("g-1"); const applicantUserId = builder.createString("user-1"); const raceName = builder.createString("Vegan Federation"); const statusOff = builder.createString(status); ApplicationSummary.startApplicationSummary(builder); ApplicationSummary.addApplicationId(builder, applicationId); ApplicationSummary.addGameId(builder, gameId); ApplicationSummary.addApplicantUserId(builder, applicantUserId); ApplicationSummary.addRaceName(builder, raceName); ApplicationSummary.addStatus(builder, statusOff); ApplicationSummary.addCreatedAtMs(builder, 1_770_000_000_000n); ApplicationSummary.addDecidedAtMs(builder, status === "pending" ? 0n : 1_770_010_000_000n); return ApplicationSummary.endApplicationSummary(builder); } function encodeInviteSummary(builder: Builder, status: string): number { const inviteId = builder.createString("invite-1"); const gameId = builder.createString("g-1"); const inviter = builder.createString("user-host"); const invited = builder.createString("user-1"); const code = builder.createString(""); const race = builder.createString("Vegan Federation"); const statusOff = builder.createString(status); InviteSummary.startInviteSummary(builder); InviteSummary.addInviteId(builder, inviteId); InviteSummary.addGameId(builder, gameId); InviteSummary.addInviterUserId(builder, inviter); InviteSummary.addInvitedUserId(builder, invited); InviteSummary.addCode(builder, code); InviteSummary.addRaceName(builder, race); InviteSummary.addStatus(builder, statusOff); InviteSummary.addCreatedAtMs(builder, 1_770_000_000_000n); InviteSummary.addExpiresAtMs(builder, 1_780_000_000_000n); InviteSummary.addDecidedAtMs(builder, status === "pending" ? 0n : 1_770_010_000_000n); return InviteSummary.endInviteSummary(builder); } describe("lobby.ts wrappers", () => { test("listMyGames decodes the response and reports the message type", async () => { const { client, captured } = makeStub(() => { const builder = new Builder(256); const item = encodeGameSummary(builder, 5); const items = MyGamesListResponse.createItemsVector(builder, [item]); MyGamesListResponse.startMyGamesListResponse(builder); MyGamesListResponse.addItems(builder, items); builder.finish(MyGamesListResponse.endMyGamesListResponse(builder)); return { payloadBytes: builder.asUint8Array() }; }); const games = await listMyGames(client); expect(captured[0]!.messageType).toBe("lobby.my.games.list"); expect(games.length).toBe(1); expect(games[0]!.gameId).toBe("g-1"); expect(games[0]!.minPlayers).toBe(2); expect(games[0]!.currentTurn).toBe(5); }); test("listPublicGames passes pagination and decodes pageSize/total", async () => { const { client, captured } = makeStub(() => { const builder = new Builder(256); const item = encodeGameSummary(builder); const items = PublicGamesListResponse.createItemsVector(builder, [item]); PublicGamesListResponse.startPublicGamesListResponse(builder); PublicGamesListResponse.addItems(builder, items); PublicGamesListResponse.addPage(builder, 2); PublicGamesListResponse.addPageSize(builder, 25); PublicGamesListResponse.addTotal(builder, 51); builder.finish(PublicGamesListResponse.endPublicGamesListResponse(builder)); return { payloadBytes: builder.asUint8Array() }; }); const page = await listPublicGames(client, { page: 2, pageSize: 25 }); expect(captured[0]!.messageType).toBe("lobby.public.games.list"); const decodedRequest = PublicGamesListRequest.getRootAsPublicGamesListRequest( new ByteBuffer(captured[0]!.payload), ); expect(decodedRequest.page()).toBe(2); expect(decodedRequest.pageSize()).toBe(25); expect(page.items.length).toBe(1); expect(page.page).toBe(2); expect(page.pageSize).toBe(25); expect(page.total).toBe(51); }); test("listMyApplications decodes pending and decided records", async () => { const { client } = makeStub(() => { const builder = new Builder(256); const pending = encodeApplicationSummary(builder, "pending"); const approved = encodeApplicationSummary(builder, "approved"); const items = MyApplicationsListResponse.createItemsVector(builder, [pending, approved]); MyApplicationsListResponse.startMyApplicationsListResponse(builder); MyApplicationsListResponse.addItems(builder, items); builder.finish(MyApplicationsListResponse.endMyApplicationsListResponse(builder)); return { payloadBytes: builder.asUint8Array() }; }); const applications = await listMyApplications(client); expect(applications.length).toBe(2); expect(applications[0]!.status).toBe("pending"); expect(applications[0]!.decidedAt).toBeNull(); expect(applications[1]!.status).toBe("approved"); expect(applications[1]!.decidedAt).not.toBeNull(); }); test("listMyInvites decodes user-bound invites", async () => { const { client } = makeStub(() => { const builder = new Builder(256); const invite = encodeInviteSummary(builder, "pending"); const items = MyInvitesListResponse.createItemsVector(builder, [invite]); MyInvitesListResponse.startMyInvitesListResponse(builder); MyInvitesListResponse.addItems(builder, items); builder.finish(MyInvitesListResponse.endMyInvitesListResponse(builder)); return { payloadBytes: builder.asUint8Array() }; }); const invites = await listMyInvites(client); expect(invites.length).toBe(1); expect(invites[0]!.invitedUserId).toBe("user-1"); expect(invites[0]!.status).toBe("pending"); expect(invites[0]!.decidedAt).toBeNull(); }); test("createGame encodes every field and decodes the returned summary", async () => { const { client, captured } = makeStub(() => { const builder = new Builder(256); const game = encodeGameSummary(builder); GameCreateResponse.startGameCreateResponse(builder); GameCreateResponse.addGame(builder, game); builder.finish(GameCreateResponse.endGameCreateResponse(builder)); return { payloadBytes: builder.asUint8Array() }; }); const enrollment = new Date(1_780_000_000_000); const result = await createGame(client, { gameName: "First Contact", description: "", minPlayers: 2, maxPlayers: 8, startGapHours: 24, startGapPlayers: 2, enrollmentEndsAt: enrollment, turnSchedule: "0 0 * * *", targetEngineVersion: "v1", }); expect(captured[0]!.messageType).toBe("lobby.game.create"); const request = GameCreateRequest.getRootAsGameCreateRequest( new ByteBuffer(captured[0]!.payload), ); expect(request.gameName()).toBe("First Contact"); expect(request.turnSchedule()).toBe("0 0 * * *"); expect(request.targetEngineVersion()).toBe("v1"); expect(request.minPlayers()).toBe(2); expect(request.maxPlayers()).toBe(8); expect(request.enrollmentEndsAtMs()).toBe(BigInt(enrollment.getTime())); expect(result.gameId).toBe("g-1"); }); test("submitApplication encodes game_id and race_name", async () => { const { client, captured } = makeStub(() => { const builder = new Builder(128); const app = encodeApplicationSummary(builder, "pending"); ApplicationSubmitResponse.startApplicationSubmitResponse(builder); ApplicationSubmitResponse.addApplication(builder, app); builder.finish(ApplicationSubmitResponse.endApplicationSubmitResponse(builder)); return { payloadBytes: builder.asUint8Array() }; }); const submitted = await submitApplication(client, "public-1", "Vegan Federation"); expect(captured[0]!.messageType).toBe("lobby.application.submit"); const decoded = ApplicationSubmitRequest.getRootAsApplicationSubmitRequest( new ByteBuffer(captured[0]!.payload), ); expect(decoded.gameId()).toBe("public-1"); expect(decoded.raceName()).toBe("Vegan Federation"); expect(submitted.applicationId).toBe("app-1"); }); test("redeemInvite and declineInvite hit their respective message types", async () => { const stubRedeem = makeStub(() => { const builder = new Builder(128); const invite = encodeInviteSummary(builder, "accepted"); InviteRedeemResponse.startInviteRedeemResponse(builder); InviteRedeemResponse.addInvite(builder, invite); builder.finish(InviteRedeemResponse.endInviteRedeemResponse(builder)); return { payloadBytes: builder.asUint8Array() }; }); const redeemed = await redeemInvite(stubRedeem.client, "private-1", "invite-1"); expect(stubRedeem.captured[0]!.messageType).toBe("lobby.invite.redeem"); const redeemReq = InviteRedeemRequest.getRootAsInviteRedeemRequest( new ByteBuffer(stubRedeem.captured[0]!.payload), ); expect(redeemReq.gameId()).toBe("private-1"); expect(redeemReq.inviteId()).toBe("invite-1"); expect(redeemed.status).toBe("accepted"); const stubDecline = makeStub(() => { const builder = new Builder(128); const invite = encodeInviteSummary(builder, "declined"); InviteDeclineResponse.startInviteDeclineResponse(builder); InviteDeclineResponse.addInvite(builder, invite); builder.finish(InviteDeclineResponse.endInviteDeclineResponse(builder)); return { payloadBytes: builder.asUint8Array() }; }); const declined = await declineInvite(stubDecline.client, "private-1", "invite-1"); expect(stubDecline.captured[0]!.messageType).toBe("lobby.invite.decline"); const declineReq = InviteDeclineRequest.getRootAsInviteDeclineRequest( new ByteBuffer(stubDecline.captured[0]!.payload), ); expect(declineReq.gameId()).toBe("private-1"); expect(declineReq.inviteId()).toBe("invite-1"); expect(declined.status).toBe("declined"); }); test("non-ok result codes are surfaced as a LobbyError with code and message", async () => { const { client } = makeStub(() => { const builder = new Builder(128); const code = builder.createString("conflict"); const message = builder.createString("game is not in enrollment_open"); ErrorBody.startErrorBody(builder); ErrorBody.addCode(builder, code); ErrorBody.addMessage(builder, message); const errorOff = ErrorBody.endErrorBody(builder); ErrorResponse.startErrorResponse(builder); ErrorResponse.addError(builder, errorOff); builder.finish(ErrorResponse.endErrorResponse(builder)); return { resultCode: "conflict", payloadBytes: builder.asUint8Array() }; }); await expect(submitApplication(client, "public-1", "race")).rejects.toThrow(LobbyError); try { await submitApplication(client, "public-1", "race"); } catch (err) { const lobbyError = err as LobbyError; expect(lobbyError.code).toBe("conflict"); expect(lobbyError.message).toBe("game is not in enrollment_open"); expect(lobbyError.resultCode).toBe("conflict"); } }); });