Files
galaxy-game/ui/frontend/tests/lobby-api.test.ts
T
Ilia Denisov f57a290432 phase 8: lobby UI + cross-stack lobby command catalog + TS FlatBuffers
- Extend pkg/model/lobby and pkg/schema/fbs/lobby.fbs with public-games
  list, my-applications/invites lists, game-create, application-submit,
  invite-redeem/decline. Mirror the matching transcoder pairs and Go
  fixture round-trip tests.
- Wire the seven new lobby message types through
  gateway/internal/backendclient/{routes,lobby_commands}.go with
  per-command REST helpers, JSON-tolerant decoding of backend wire
  shapes, and httptest-based unit coverage for success / 4xx / 5xx /
  503 across each command.
- Introduce TS-side FlatBuffers via the `flatbuffers` runtime dep, a
  `make fbs-ts` target driving flatc, and the generated bindings under
  ui/frontend/src/proto/galaxy/fbs. Phase 7's `user.account.get` decode
  now uses these bindings as well, closing the JSON.parse vs
  FlatBuffers gap that would have failed against a real local stack.
- Replace the placeholder lobby with five sections (my games, pending
  invitations, my applications, public games, create new game) and the
  /lobby/create form. Submit-application uses an inline race-name
  form on the public-game card; create-game keeps name / description /
  turn_schedule / enrollment_ends_at always visible and the rest under
  an Advanced toggle with TS-side defaults.
- Update lobby/+page.svelte to throw LobbyError on non-ok result codes;
  GalaxyClient.executeCommand now returns { resultCode, payloadBytes }.
- Vitest binding round-trips, lobby.ts wrapper unit tests, lobby-page
  + lobby-create component tests, Playwright lobby-flow.spec covering
  create / submit / accept across all four projects. Phase 7 e2e was
  migrated to the FlatBuffers fixtures and to click+fill against the
  Safari-autofill readonly inputs.
- Mark Phase 8 done in ui/PLAN.md, mirror the wire-format note into
  Phase 7, append the new lobby commands to gateway/README.md and
  docs/ARCHITECTURE.md, add ui/docs/lobby.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:05:08 +02:00

336 lines
13 KiB
TypeScript

// 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): 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);
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);
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);
});
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");
}
});
});