feat(lobby): F8-04b hierarchical sidebar + paid-tier gate for create-game
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:
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user