// F8-04b regression spec: recruitment cards merge public games with // the caller's applications and surface the application status as a // chip. The inline race-name form must be visible when there is no // application or when the latest application is `rejected` (re-apply // flow). Pending / approved applications hide the form. import { fromJson, type JsonValue } from "@bufbuild/protobuf"; import { expect, test, type Page } from "@playwright/test"; import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb"; import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response"; import { buildAccountResponsePayload, buildMyApplicationsListPayload, buildMyGamesListPayload, buildMyInvitesListPayload, buildPublicGamesListPayload, type ApplicationFixture, type GameFixture, } from "./fixtures/lobby-fbs"; interface BadgeMocks { pendingSubscribes: Array<() => void>; } async function mockGateway( page: Page, opts: { games: GameFixture[]; applications: ApplicationFixture[] }, ): Promise { const mocks: BadgeMocks = { pendingSubscribes: [] }; await page.route("**/api/v1/public/auth/send-email-code", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ challenge_id: "ch-badge-1" }), }); }); await page.route("**/api/v1/public/auth/confirm-email-code", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ device_session_id: "dev-badge-1" }), }); }); await page.route("**/edge.v1.Gateway/ExecuteCommand", async (route) => { const reqText = route.request().postData(); if (reqText === null) { await route.fulfill({ status: 400 }); return; } const req = fromJson( ExecuteCommandRequestSchema, JSON.parse(reqText) as JsonValue, ); let payload: Uint8Array; switch (req.messageType) { case "user.account.get": payload = buildAccountResponsePayload({ userId: "user-badge", email: "pilot+badge@example.com", userName: "pilot", displayName: "Pilot", }); break; case "lobby.my.games.list": payload = buildMyGamesListPayload([]); break; case "lobby.public.games.list": payload = buildPublicGamesListPayload(opts.games); break; case "lobby.my.invites.list": payload = buildMyInvitesListPayload([]); break; case "lobby.my.applications.list": payload = buildMyApplicationsListPayload(opts.applications); break; default: payload = new Uint8Array(); break; } const responseJson = await forgeExecuteCommandResponseJson({ requestId: req.requestId, timestampMs: BigInt(Date.now()), resultCode: "ok", payloadBytes: payload, }); await route.fulfill({ status: 200, contentType: "application/json", body: responseJson, }); }); await page.route("**/edge.v1.Gateway/SubscribeEvents", async (route) => { const action = await new Promise<"endOfStream" | "abort">((resolve) => { mocks.pendingSubscribes.push(() => resolve("endOfStream")); }); if (action === "abort") { await route.abort(); return; } const body = new TextEncoder().encode("{}"); const frame = new Uint8Array(5 + body.length); frame[0] = 0x02; new DataView(frame.buffer).setUint32(1, body.length, false); frame.set(body, 5); await route.fulfill({ status: 200, contentType: "application/connect+json", body: Buffer.from(frame), }); }); return mocks; } async function completeLogin(page: Page): Promise { await page.goto("/"); await page.getByTestId("login-email-input").click(); await page.getByTestId("login-email-input").fill("pilot+badge@example.com"); await page.getByTestId("login-email-submit").click(); await page.getByTestId("login-code-input").click(); await page.getByTestId("login-code-input").fill("123456"); await page.getByTestId("login-code-submit").click(); await expect(page.getByTestId("lobby-account-name")).toBeVisible(); } test.describe("F8-04b — recruitment status badges", () => { test("pending application hides the inline form and shows the chip", async ({ page, }) => { const game: GameFixture = { gameId: "public-pending", gameName: "Pending Game", gameType: "public", status: "enrollment_open", ownerUserId: "other-owner", }; const app: ApplicationFixture = { applicationId: "app-pending", gameId: "public-pending", applicantUserId: "user-badge", raceName: "Race Pending", status: "pending", createdAtMs: 1n, }; const mocks = await mockGateway(page, { games: [game], applications: [app] }); await completeLogin(page); const card = page.getByTestId("lobby-recruitment-card"); await expect(card).toBeVisible(); await expect(page.getByTestId("lobby-application-status-chip")).toContainText( /pending/i, ); // Inline form is hidden for pending — re-apply not allowed. await expect(page.getByTestId("lobby-public-game-apply")).toBeHidden(); await expect(page.getByTestId("lobby-application-form")).toBeHidden(); mocks.pendingSubscribes.forEach((resolve) => resolve()); }); test("rejected application shows the chip AND keeps the inline form visible", async ({ page, }) => { const game: GameFixture = { gameId: "public-rejected", gameName: "Rejected Game", gameType: "public", status: "enrollment_open", ownerUserId: "other-owner", }; const app: ApplicationFixture = { applicationId: "app-rejected", gameId: "public-rejected", applicantUserId: "user-badge", raceName: "Race Rejected", status: "rejected", createdAtMs: 1n, }; const mocks = await mockGateway(page, { games: [game], applications: [app] }); await completeLogin(page); await expect(page.getByTestId("lobby-recruitment-card")).toBeVisible(); await expect(page.getByTestId("lobby-application-status-chip")).toContainText( /rejected/i, ); // Re-apply button is visible for rejected — owner-confirmed F8-04b // behaviour. await expect(page.getByTestId("lobby-public-game-apply")).toBeVisible(); mocks.pendingSubscribes.forEach((resolve) => resolve()); }); test("approved application hides the inline form and shows the chip", async ({ page, }) => { const game: GameFixture = { gameId: "public-approved", gameName: "Approved Game", gameType: "public", status: "enrollment_open", ownerUserId: "other-owner", }; const app: ApplicationFixture = { applicationId: "app-approved", gameId: "public-approved", applicantUserId: "user-badge", raceName: "Race Approved", status: "approved", createdAtMs: 1n, }; const mocks = await mockGateway(page, { games: [game], applications: [app] }); await completeLogin(page); await expect(page.getByTestId("lobby-application-status-chip")).toContainText( /approved/i, ); await expect(page.getByTestId("lobby-public-game-apply")).toBeHidden(); mocks.pendingSubscribes.forEach((resolve) => resolve()); }); test("no application leaves the inline race-name form visible", async ({ page, }) => { const game: GameFixture = { gameId: "public-new", gameName: "New Game", gameType: "public", status: "enrollment_open", ownerUserId: "other-owner", }; const mocks = await mockGateway(page, { games: [game], applications: [] }); await completeLogin(page); await expect(page.getByTestId("lobby-recruitment-card")).toBeVisible(); // No application → no chip, but the apply button is there. await expect(page.getByTestId("lobby-application-status-chip")).toBeHidden(); await expect(page.getByTestId("lobby-public-game-apply")).toBeVisible(); mocks.pendingSubscribes.forEach((resolve) => resolve()); }); });