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
+35 -15
View File
@@ -42,9 +42,14 @@ interface LobbyMocks {
createGameCalls: GameFixture[];
applicationSubmitCalls: Array<{ gameId: string; raceName: string }>;
inviteRedeemCalls: Array<{ gameId: string; inviteId: string }>;
accountIsPaid: boolean;
}
async function mockGateway(page: Page, initial: Partial<LobbyState> = {}): Promise<LobbyMocks> {
interface MockOptions extends Partial<LobbyState> {
isPaid?: boolean;
}
async function mockGateway(page: Page, initial: MockOptions = {}): Promise<LobbyMocks> {
const mocks: LobbyMocks = {
state: {
myGames: initial.myGames ?? [],
@@ -56,6 +61,7 @@ async function mockGateway(page: Page, initial: Partial<LobbyState> = {}): Promi
createGameCalls: [],
applicationSubmitCalls: [],
inviteRedeemCalls: [],
accountIsPaid: initial.isPaid ?? false,
};
await page.route("**/api/v1/public/auth/send-email-code", async (route) => {
@@ -94,6 +100,7 @@ async function mockGateway(page: Page, initial: Partial<LobbyState> = {}): Promi
email: "pilot@example.com",
userName: "pilot",
displayName: "Pilot",
isPaid: mocks.accountIsPaid,
});
break;
case "lobby.my.games.list":
@@ -255,16 +262,20 @@ async function completeLogin(page: Page): Promise<void> {
}
test.describe("Phase 8 — lobby flow", () => {
test("create-game flow lands the new game in My Games", async ({ page }) => {
const mocks = await mockGateway(page);
test("paid-tier owner creates a private game and lands on the private-games panel", async ({
page,
}) => {
const mocks = await mockGateway(page, { isPaid: true });
await completeLogin(page);
await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible();
await expect(page.getByTestId("lobby-public-games-empty")).toBeVisible();
// Default landing is `games-recruitment` (empty, no public games).
await expect(page.getByTestId("lobby-recruitment-empty")).toBeVisible();
// Paid tier exposes the `private games` sub-panel; navigate to it.
await page.getByTestId("lobby-nav-games-private-games").click();
await expect(page.getByTestId("lobby-games-private-empty")).toBeVisible();
await page.getByTestId("lobby-create-button").click();
// The create screen replaces the lobby in place (no `/lobby/create`
// route); the create form is the visible signal.
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
await page.getByTestId("lobby-create-game-name").click();
@@ -276,16 +287,18 @@ test.describe("Phase 8 — lobby flow", () => {
.fill("2026-06-01T12:00");
await page.getByTestId("lobby-create-submit").click();
// Submit returns to the lobby in place; the new game card is the
// visible signal that the lobby re-rendered.
await expect(page.getByTestId("lobby-my-game-card")).toContainText("First Contact");
// Submit returns to the private-games sub-panel; the new game
// card is the visible signal that the lobby data refreshed.
await expect(page.getByTestId("lobby-private-game-card")).toContainText(
"First Contact",
);
expect(mocks.createGameCalls.length).toBe(1);
expect(mocks.createGameCalls[0]!.gameName).toBe("First Contact");
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("submitting an application produces a pending applications card", async ({
test("submitting an application produces a status chip on the recruitment card", async ({
page,
}) => {
const mocks = await mockGateway(page, {
@@ -300,14 +313,17 @@ test.describe("Phase 8 — lobby flow", () => {
});
await completeLogin(page);
await expect(page.getByTestId("lobby-public-game-apply")).toBeVisible();
// Default landing for a no-games account is the recruitment panel.
await expect(page.getByTestId("lobby-recruitment-card")).toBeVisible();
await page.getByTestId("lobby-public-game-apply").click();
await page
.getByTestId("lobby-application-race-name")
.fill("Vegan Federation");
await page.getByTestId("lobby-application-submit").click();
await expect(page.getByTestId("lobby-application-card")).toBeVisible();
// After submit the inline form collapses and the recruitment card
// surfaces the status chip with the new `pending` application.
await expect(page.getByTestId("lobby-application-status-chip")).toBeVisible();
expect(mocks.applicationSubmitCalls).toEqual([
{ gameId: "public-1", raceName: "Vegan Federation" },
]);
@@ -315,7 +331,7 @@ test.describe("Phase 8 — lobby flow", () => {
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("accepting an invitation removes it and adds the game to My Games", async ({
test("accepting an invitation removes it and adds the game to active-past", async ({
page,
}) => {
const mocks = await mockGateway(page, {
@@ -332,10 +348,14 @@ test.describe("Phase 8 — lobby flow", () => {
});
await completeLogin(page);
// Navigate to the invitations sub-panel.
await page.getByTestId("lobby-nav-games-invitations").click();
await expect(page.getByTestId("lobby-invite-accept")).toBeVisible();
await page.getByTestId("lobby-invite-accept").click();
await expect(page.getByTestId("lobby-invite-accept")).toBeHidden();
// Active-past now has the invited game.
await page.getByTestId("lobby-nav-games-active-past").click();
await expect(page.getByTestId("lobby-my-game-card")).toContainText("Invited Game");
expect(mocks.inviteRedeemCalls).toEqual([
{ gameId: "private-1", inviteId: "invite-1" },