f42ab87233
The desktop submenu (.desktop-only) is CSS-hidden on mobile viewports — the mobile sidebar tucks the same sub-panel entries behind a dropdown popover. Assert `toBeAttached()` instead of `toBeVisible()` so the dev-bundle smoke check works on every viewport. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
241 lines
8.3 KiB
TypeScript
241 lines
8.3 KiB
TypeScript
// F8-04b regression spec: paid-tier gate on the `private games`
|
|
// sub-panel and the `create new game` button. The gateway is mocked
|
|
// at the message-type level (same shape as lobby-flow.spec.ts) so the
|
|
// account aggregate carries either is_paid=false (free) or
|
|
// is_paid=true (paid). The tests assert sidebar visibility and the
|
|
// inline forbidden message produced by the lobby-create screen when
|
|
// the backend rejects a `lobby.game.create` from a free-tier caller.
|
|
|
|
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,
|
|
buildLobbyErrorPayload,
|
|
} from "./fixtures/lobby-fbs";
|
|
|
|
interface TierMocks {
|
|
pendingSubscribes: Array<() => void>;
|
|
createGameCalls: number;
|
|
}
|
|
|
|
async function mockGatewayTier(
|
|
page: Page,
|
|
opts: { isPaid: boolean; rejectCreate?: boolean },
|
|
): Promise<TierMocks> {
|
|
const mocks: TierMocks = {
|
|
pendingSubscribes: [],
|
|
createGameCalls: 0,
|
|
};
|
|
|
|
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-tier-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-tier-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 resultCode = "ok";
|
|
let payload: Uint8Array;
|
|
switch (req.messageType) {
|
|
case "user.account.get":
|
|
payload = buildAccountResponsePayload({
|
|
userId: "user-tier",
|
|
email: "pilot+tier@example.com",
|
|
userName: "pilot",
|
|
displayName: "Pilot",
|
|
isPaid: opts.isPaid,
|
|
});
|
|
break;
|
|
case "lobby.my.games.list":
|
|
payload = buildMyGamesListPayload([]);
|
|
break;
|
|
case "lobby.public.games.list":
|
|
payload = buildPublicGamesListPayload([]);
|
|
break;
|
|
case "lobby.my.invites.list":
|
|
payload = buildMyInvitesListPayload([]);
|
|
break;
|
|
case "lobby.my.applications.list":
|
|
payload = buildMyApplicationsListPayload([]);
|
|
break;
|
|
case "lobby.game.create":
|
|
mocks.createGameCalls += 1;
|
|
if (opts.rejectCreate === true) {
|
|
resultCode = "forbidden";
|
|
payload = buildLobbyErrorPayload(
|
|
"forbidden",
|
|
"creating private games requires a paid subscription",
|
|
);
|
|
} else {
|
|
// Tests that allow create return a minimal valid payload
|
|
// — but we only need the rejection path here.
|
|
resultCode = "internal_error";
|
|
payload = new Uint8Array();
|
|
}
|
|
break;
|
|
default:
|
|
resultCode = "internal_error";
|
|
payload = new Uint8Array();
|
|
break;
|
|
}
|
|
|
|
const responseJson = await forgeExecuteCommandResponseJson({
|
|
requestId: req.requestId,
|
|
timestampMs: BigInt(Date.now()),
|
|
resultCode,
|
|
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 expect(page.getByTestId("login-email-input")).toBeVisible();
|
|
await page.getByTestId("login-email-input").click();
|
|
await page.getByTestId("login-email-input").fill("pilot+tier@example.com");
|
|
await page.getByTestId("login-email-submit").click();
|
|
await expect(page.getByTestId("login-code-input")).toBeVisible();
|
|
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 — tier gate", () => {
|
|
test("free-tier session hides the private-games sub-panel and the create button", async ({
|
|
page,
|
|
}) => {
|
|
// Note: this assertion exercises the runtime check
|
|
// (account.entitlement.isPaid). The build-time
|
|
// VITE_GALAXY_DEV_AFFORDANCES flag is `true` in the dev bundle
|
|
// the e2e suite runs against, so the sub-panel WOULD be visible
|
|
// without the runtime check. The shell falls back to the
|
|
// runtime check whenever DEV_AFFORDANCES is also true — that's
|
|
// the path this test pins.
|
|
const mocks = await mockGatewayTier(page, { isPaid: false });
|
|
await completeLogin(page);
|
|
|
|
// Default landing is `games-recruitment`.
|
|
await expect(page.getByTestId("lobby-recruitment-empty")).toBeVisible();
|
|
|
|
// In a true prod bundle the private-games entry would be
|
|
// absent. The dev bundle keeps it via VITE_GALAXY_DEV_AFFORDANCES;
|
|
// this assertion documents the dev-bundle behaviour and acts as
|
|
// a smoke test that the runtime predicate at least evaluates
|
|
// account.entitlement.is_paid without throwing. Use `attached`
|
|
// instead of `visible` because the desktop submenu button is
|
|
// CSS-hidden on the mobile viewports — the mobile sidebar
|
|
// tucks the same entry behind a dropdown popover.
|
|
const privateGamesEntry = page.getByTestId("lobby-nav-games-private-games");
|
|
await expect(privateGamesEntry).toBeAttached();
|
|
|
|
// Free-tier callers reach the create form via the DEV-visible
|
|
// entry, but the backend still rejects the POST. Use the dev
|
|
// nav surface so the assertion works on mobile too.
|
|
await page.evaluate(() => window.__galaxyNav!.go("games-private-games"));
|
|
await page.getByTestId("lobby-create-button").click();
|
|
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
|
|
|
|
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
|
});
|
|
|
|
test("backend forbidden surfaces an inline paid-tier message on lobby-create", async ({
|
|
page,
|
|
}) => {
|
|
const mocks = await mockGatewayTier(page, {
|
|
isPaid: false,
|
|
rejectCreate: true,
|
|
});
|
|
await completeLogin(page);
|
|
|
|
await page.evaluate(() => window.__galaxyNav!.go("games-private-games"));
|
|
await page.getByTestId("lobby-create-button").click();
|
|
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
|
|
|
|
await page.getByTestId("lobby-create-game-name").click();
|
|
await page.getByTestId("lobby-create-game-name").fill("Forbidden Game");
|
|
await page.getByTestId("lobby-create-turn-schedule").click();
|
|
await page.getByTestId("lobby-create-turn-schedule").fill("0 0 * * *");
|
|
await page
|
|
.getByTestId("lobby-create-enrollment-ends-at")
|
|
.fill("2026-06-01T12:00");
|
|
await page.getByTestId("lobby-create-submit").click();
|
|
|
|
// Inline error stays on the create form (no redirect, no toast).
|
|
await expect(page.getByTestId("lobby-create-error")).toContainText(
|
|
"paid",
|
|
);
|
|
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
|
|
expect(mocks.createGameCalls).toBe(1);
|
|
|
|
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
|
});
|
|
|
|
test("paid-tier session shows the private-games sub-panel and routes the create-button to the form", async ({
|
|
page,
|
|
}) => {
|
|
const mocks = await mockGatewayTier(page, { isPaid: true });
|
|
await completeLogin(page);
|
|
|
|
await page.evaluate(() => window.__galaxyNav!.go("games-private-games"));
|
|
await expect(page.getByTestId("lobby-games-private-empty")).toBeVisible();
|
|
await expect(page.getByTestId("lobby-create-button")).toBeVisible();
|
|
await page.getByTestId("lobby-create-button").click();
|
|
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
|
|
|
|
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
|
});
|
|
});
|