feat(lobby): F8-04b hierarchical sidebar + paid-tier gate for create-game
Tests · Go / test (push) Successful in 2m17s
Tests · UI / test (push) Waiting to run

Reshape the lobby UI from a single Overview into a two-level sidebar
(games · profile · DEV synthetic-reports) with four games sub-panels
(active-past · recruitment · invitations · private-games). Move the
`create new game` button into the private-games panel, merge the
applications section into recruitment cards as status chips, and add
DEV-only synthetic-report loader as a top-level screen.

Add a paid-tier gate at backend `lobby.game.create`: free callers get
`403 forbidden` before the lobby service is invoked. The UI hides the
private-games sub-panel + create button on free tier (DEV affordances
flag overrides). Update every integration test that creates a game to
use a new `testenv.PromoteToPaid` helper; add a new
`TestLobbyFlow_FreeUserCreateGameForbidden`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-26 23:53:53 +02:00
parent 98d1fe6cae
commit 009ea560f9
44 changed files with 2486 additions and 1118 deletions
@@ -0,0 +1,244 @@
// 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<BadgeMocks> {
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<void> {
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());
});
});