009ea560f9
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>
245 lines
7.5 KiB
TypeScript
245 lines
7.5 KiB
TypeScript
// 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());
|
|
});
|
|
});
|