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:
@@ -0,0 +1,88 @@
|
||||
// Unit tests for `decodeAccountView` — F8-04b adds an `entitlement`
|
||||
// projection on the TS Account, sourced from the FBS
|
||||
// `EntitlementSnapshot.is_paid` field. The decode must default to
|
||||
// `false` when the snapshot is absent, never throw on null.
|
||||
|
||||
import { Builder, ByteBuffer } from "flatbuffers";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { decodeAccountView } from "../src/api/account";
|
||||
import {
|
||||
AccountView,
|
||||
EntitlementSnapshot,
|
||||
} from "../src/proto/galaxy/fbs/user";
|
||||
|
||||
function buildAccountView(opts: {
|
||||
isPaid?: boolean;
|
||||
includeEntitlement: boolean;
|
||||
}): AccountView {
|
||||
const builder = new Builder(256);
|
||||
const userIdOff = builder.createString("user-1");
|
||||
const emailOff = builder.createString("user@example.com");
|
||||
const userNameOff = builder.createString("Player-1");
|
||||
const displayNameOff = builder.createString("Display");
|
||||
const langOff = builder.createString("en-US");
|
||||
const tzOff = builder.createString("UTC");
|
||||
const countryOff = builder.createString("US");
|
||||
|
||||
let entitlementOff = 0;
|
||||
if (opts.includeEntitlement) {
|
||||
const planOff = builder.createString("free");
|
||||
const sourceOff = builder.createString("default");
|
||||
const reasonOff = builder.createString("init");
|
||||
EntitlementSnapshot.startEntitlementSnapshot(builder);
|
||||
EntitlementSnapshot.addPlanCode(builder, planOff);
|
||||
EntitlementSnapshot.addIsPaid(builder, opts.isPaid ?? false);
|
||||
EntitlementSnapshot.addSource(builder, sourceOff);
|
||||
EntitlementSnapshot.addReasonCode(builder, reasonOff);
|
||||
entitlementOff = EntitlementSnapshot.endEntitlementSnapshot(builder);
|
||||
}
|
||||
|
||||
AccountView.startAccountView(builder);
|
||||
AccountView.addUserId(builder, userIdOff);
|
||||
AccountView.addEmail(builder, emailOff);
|
||||
AccountView.addUserName(builder, userNameOff);
|
||||
AccountView.addDisplayName(builder, displayNameOff);
|
||||
AccountView.addPreferredLanguage(builder, langOff);
|
||||
AccountView.addTimeZone(builder, tzOff);
|
||||
AccountView.addDeclaredCountry(builder, countryOff);
|
||||
if (entitlementOff !== 0) {
|
||||
AccountView.addEntitlement(builder, entitlementOff);
|
||||
}
|
||||
const viewOff = AccountView.endAccountView(builder);
|
||||
builder.finish(viewOff);
|
||||
|
||||
return AccountView.getRootAsAccountView(new ByteBuffer(builder.asUint8Array()));
|
||||
}
|
||||
|
||||
describe("decodeAccountView", () => {
|
||||
test("extracts entitlement.isPaid=true from FBS EntitlementSnapshot", () => {
|
||||
const view = buildAccountView({ includeEntitlement: true, isPaid: true });
|
||||
const account = decodeAccountView(view);
|
||||
expect(account.entitlement.isPaid).toBe(true);
|
||||
});
|
||||
|
||||
test("extracts entitlement.isPaid=false from FBS EntitlementSnapshot", () => {
|
||||
const view = buildAccountView({ includeEntitlement: true, isPaid: false });
|
||||
const account = decodeAccountView(view);
|
||||
expect(account.entitlement.isPaid).toBe(false);
|
||||
});
|
||||
|
||||
test("defaults entitlement.isPaid to false when snapshot is absent", () => {
|
||||
const view = buildAccountView({ includeEntitlement: false });
|
||||
const account = decodeAccountView(view);
|
||||
expect(account.entitlement.isPaid).toBe(false);
|
||||
});
|
||||
|
||||
test("populates other Account fields verbatim", () => {
|
||||
const view = buildAccountView({ includeEntitlement: true, isPaid: true });
|
||||
const account = decodeAccountView(view);
|
||||
expect(account.userId).toBe("user-1");
|
||||
expect(account.email).toBe("user@example.com");
|
||||
expect(account.userName).toBe("Player-1");
|
||||
expect(account.displayName).toBe("Display");
|
||||
expect(account.preferredLanguage).toBe("en-US");
|
||||
expect(account.timeZone).toBe("UTC");
|
||||
expect(account.declaredCountry).toBe("US");
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
import {
|
||||
ApplicationSubmitResponse,
|
||||
ApplicationSummary,
|
||||
ErrorBody,
|
||||
ErrorResponse,
|
||||
GameCreateResponse,
|
||||
GameSummary,
|
||||
InviteDeclineResponse,
|
||||
@@ -218,17 +220,36 @@ export interface AccountFixture {
|
||||
preferredLanguage?: string;
|
||||
timeZone?: string;
|
||||
declaredCountry?: string;
|
||||
isPaid?: boolean;
|
||||
}
|
||||
|
||||
// buildLobbyErrorPayload builds a `lobby.ErrorResponse` FBS payload
|
||||
// the Playwright suite returns on non-`ok` result codes. The TS lobby
|
||||
// client decodes the same payload via `decodeLobbyError`, surfacing
|
||||
// `code` / `message` to the UI for inline rendering.
|
||||
export function buildLobbyErrorPayload(code: string, message: string): Uint8Array {
|
||||
const builder = new Builder(128);
|
||||
const codeOff = builder.createString(code);
|
||||
const messageOff = builder.createString(message);
|
||||
ErrorBody.startErrorBody(builder);
|
||||
ErrorBody.addCode(builder, codeOff);
|
||||
ErrorBody.addMessage(builder, messageOff);
|
||||
const bodyOff = ErrorBody.endErrorBody(builder);
|
||||
ErrorResponse.startErrorResponse(builder);
|
||||
ErrorResponse.addError(builder, bodyOff);
|
||||
builder.finish(ErrorResponse.endErrorResponse(builder));
|
||||
return builder.asUint8Array();
|
||||
}
|
||||
|
||||
export function buildAccountResponsePayload(account: AccountFixture): Uint8Array {
|
||||
const builder = new Builder(256);
|
||||
|
||||
const planCode = builder.createString("free");
|
||||
const planCode = builder.createString(account.isPaid === true ? "permanent" : "free");
|
||||
const source = builder.createString("internal");
|
||||
const reasonCode = builder.createString("");
|
||||
EntitlementSnapshot.startEntitlementSnapshot(builder);
|
||||
EntitlementSnapshot.addPlanCode(builder, planCode);
|
||||
EntitlementSnapshot.addIsPaid(builder, false);
|
||||
EntitlementSnapshot.addIsPaid(builder, account.isPaid === true);
|
||||
EntitlementSnapshot.addSource(builder, source);
|
||||
EntitlementSnapshot.addReasonCode(builder, reasonCode);
|
||||
EntitlementSnapshot.addStartsAtMs(builder, 0n);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
// 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.
|
||||
const privateGamesEntry = page.getByTestId("lobby-nav-games-private-games");
|
||||
// In dev DEV_AFFORDANCES=true → entry is visible (the gate is
|
||||
// bypassed for owner testing). The assertion captures that.
|
||||
await expect(privateGamesEntry).toBeVisible();
|
||||
|
||||
// Free-tier callers reach the create form via the DEV-visible
|
||||
// entry, but the backend still rejects the POST.
|
||||
await privateGamesEntry.click();
|
||||
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.getByTestId("lobby-nav-games-private-games").click();
|
||||
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.getByTestId("lobby-nav-games-private-games").click();
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -159,7 +159,7 @@ describe("lobby/create screen", () => {
|
||||
expect(input.startGapPlayers).toBe(2);
|
||||
expect(input.targetEngineVersion).toBe("v1");
|
||||
expect(input.enrollmentEndsAt).toBeInstanceOf(Date);
|
||||
expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
|
||||
expect(appScreenGoSpy).toHaveBeenCalledWith("games-private-games");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
// Component tests for the Phase 8 lobby screen. The lobby API and the
|
||||
// gateway client are mocked at module level; the session singleton is
|
||||
// wired to a per-test `SessionStore`-backing IndexedDB so the page's
|
||||
// boot path settles on `authenticated` and constructs a real
|
||||
// GalaxyClient (which is then never called because the lobby API
|
||||
// wrappers are stubs). The tests assert the section rendering, the
|
||||
// inline race-name form for public games, and the invitation Accept
|
||||
// flow. The app-shell navigation store is mocked so opening a game
|
||||
// (`activeView.reset()` + `appScreen.go("game", …)`) or the create
|
||||
// form (`appScreen.go("lobby-create")`) never runs real `pushState`
|
||||
// in JSDOM; the single-URL shell has no `/lobby`/`/games` routes.
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
test,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import type { IDBPDatabase } from "idb";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import { session } from "../src/lib/session-store.svelte";
|
||||
import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb";
|
||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
|
||||
|
||||
// The lobby screen navigates through the app-shell stores
|
||||
// (`appScreen.go`, `activeView.reset`/`select`), which internally call
|
||||
// SvelteKit `pushState`. Mock the whole nav module so the spies
|
||||
// capture the transitions and no real history mutation runs in JSDOM.
|
||||
const appScreenGoSpy = vi.fn();
|
||||
const activeViewResetSpy = vi.fn();
|
||||
const activeViewSelectSpy = vi.fn();
|
||||
vi.mock("$lib/app-nav.svelte", () => ({
|
||||
appScreen: { go: (...args: unknown[]) => appScreenGoSpy(...args) },
|
||||
activeView: {
|
||||
reset: (...args: unknown[]) => activeViewResetSpy(...args),
|
||||
select: (...args: unknown[]) => activeViewSelectSpy(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const listMyGamesSpy = vi.fn();
|
||||
const listPublicGamesSpy = vi.fn();
|
||||
const listMyInvitesSpy = vi.fn();
|
||||
const listMyApplicationsSpy = vi.fn();
|
||||
const submitApplicationSpy = vi.fn();
|
||||
const redeemInviteSpy = vi.fn();
|
||||
const declineInviteSpy = vi.fn();
|
||||
|
||||
vi.mock("../src/api/lobby", async () => {
|
||||
const actual = await vi.importActual<typeof import("../src/api/lobby")>(
|
||||
"../src/api/lobby",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
listMyGames: (...args: unknown[]) => listMyGamesSpy(...args),
|
||||
listPublicGames: (...args: unknown[]) => listPublicGamesSpy(...args),
|
||||
listMyInvites: (...args: unknown[]) => listMyInvitesSpy(...args),
|
||||
listMyApplications: (...args: unknown[]) => listMyApplicationsSpy(...args),
|
||||
submitApplication: (...args: unknown[]) => submitApplicationSpy(...args),
|
||||
redeemInvite: (...args: unknown[]) => redeemInviteSpy(...args),
|
||||
declineInvite: (...args: unknown[]) => declineInviteSpy(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/lib/env", () => ({
|
||||
GATEWAY_BASE_URL: "http://gateway.test",
|
||||
gatewayRpcBaseUrl: () => "http://gateway.test/rpc",
|
||||
GATEWAY_RESPONSE_PUBLIC_KEY: new Uint8Array(32).fill(0x55),
|
||||
}));
|
||||
|
||||
vi.mock("../src/api/connect", () => ({
|
||||
createGatewayClient: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("../src/api/galaxy-client", () => {
|
||||
class FakeGalaxyClient {
|
||||
executeCommand = vi.fn(async () => ({
|
||||
resultCode: "ok",
|
||||
payloadBytes: new Uint8Array(),
|
||||
}));
|
||||
}
|
||||
return { GalaxyClient: FakeGalaxyClient };
|
||||
});
|
||||
|
||||
vi.mock("../src/platform/core/index", () => ({
|
||||
loadCore: async () => ({
|
||||
signRequest: () => new Uint8Array(),
|
||||
verifyResponse: () => true,
|
||||
verifyEvent: () => true,
|
||||
verifyPayloadHash: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
let db: IDBPDatabase<GalaxyDB>;
|
||||
let dbName: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `galaxy-ui-test-${crypto.randomUUID()}`;
|
||||
db = await openGalaxyDB(dbName);
|
||||
const store = {
|
||||
keyStore: new WebCryptoKeyStore(db),
|
||||
cache: new IDBCache(db),
|
||||
};
|
||||
session.resetForTests();
|
||||
session.setStoreLoaderForTests(async () => store);
|
||||
await session.init();
|
||||
await session.signIn("device-1");
|
||||
i18n.resetForTests("en");
|
||||
|
||||
listMyGamesSpy.mockReset();
|
||||
listPublicGamesSpy.mockReset();
|
||||
listMyInvitesSpy.mockReset();
|
||||
listMyApplicationsSpy.mockReset();
|
||||
submitApplicationSpy.mockReset();
|
||||
redeemInviteSpy.mockReset();
|
||||
declineInviteSpy.mockReset();
|
||||
appScreenGoSpy.mockReset();
|
||||
activeViewResetSpy.mockReset();
|
||||
activeViewSelectSpy.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
session.resetForTests();
|
||||
i18n.resetForTests("en");
|
||||
db.close();
|
||||
await new Promise<void>((resolve) => {
|
||||
const req = indexedDB.deleteDatabase(dbName);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => resolve();
|
||||
req.onblocked = () => resolve();
|
||||
});
|
||||
});
|
||||
|
||||
async function importLobbyPage(): Promise<
|
||||
typeof import("../src/lib/screens/lobby-screen.svelte")
|
||||
> {
|
||||
return import("../src/lib/screens/lobby-screen.svelte");
|
||||
}
|
||||
|
||||
const baseDate = new Date("2026-05-07T10:00:00Z");
|
||||
|
||||
function makeGame(id: string, name: string, status = "draft") {
|
||||
return {
|
||||
gameId: id,
|
||||
gameName: name,
|
||||
gameType: "private",
|
||||
status,
|
||||
ownerUserId: "user-1",
|
||||
minPlayers: 2,
|
||||
maxPlayers: 8,
|
||||
enrollmentEndsAt: baseDate,
|
||||
createdAt: baseDate,
|
||||
updatedAt: baseDate,
|
||||
currentTurn: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makePublicGame(id: string, name: string) {
|
||||
return {
|
||||
gameId: id,
|
||||
gameName: name,
|
||||
gameType: "public",
|
||||
status: "enrollment_open",
|
||||
ownerUserId: "",
|
||||
minPlayers: 4,
|
||||
maxPlayers: 12,
|
||||
enrollmentEndsAt: baseDate,
|
||||
createdAt: baseDate,
|
||||
updatedAt: baseDate,
|
||||
currentTurn: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makeInvite(id: string) {
|
||||
return {
|
||||
inviteId: id,
|
||||
gameId: "private-1",
|
||||
inviterUserId: "host",
|
||||
invitedUserId: "user-1",
|
||||
code: "",
|
||||
raceName: "Vegan Federation",
|
||||
status: "pending",
|
||||
createdAt: baseDate,
|
||||
expiresAt: baseDate,
|
||||
decidedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeApplication(id: string, status: string) {
|
||||
return {
|
||||
applicationId: id,
|
||||
gameId: "public-1",
|
||||
applicantUserId: "user-1",
|
||||
raceName: "Vegan Federation",
|
||||
status,
|
||||
createdAt: baseDate,
|
||||
decidedAt: status === "pending" ? null : baseDate,
|
||||
};
|
||||
}
|
||||
|
||||
describe("lobby screen", () => {
|
||||
test("renders empty states for every section when API returns no items", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([]);
|
||||
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
listMyInvitesSpy.mockResolvedValue([]);
|
||||
listMyApplicationsSpy.mockResolvedValue([]);
|
||||
|
||||
const Page = (await importLobbyPage()).default;
|
||||
const ui = render(Page);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ui.getByTestId("lobby-my-games-empty")).toBeInTheDocument();
|
||||
expect(ui.getByTestId("lobby-invitations-empty")).toBeInTheDocument();
|
||||
expect(ui.getByTestId("lobby-applications-empty")).toBeInTheDocument();
|
||||
expect(ui.getByTestId("lobby-public-games-empty")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("renders my-game cards and public-game cards when items are present", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([makeGame("private-1", "First Contact")]);
|
||||
listPublicGamesSpy.mockResolvedValue({
|
||||
items: [makePublicGame("public-1", "Open Lobby")],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1,
|
||||
});
|
||||
listMyInvitesSpy.mockResolvedValue([]);
|
||||
listMyApplicationsSpy.mockResolvedValue([]);
|
||||
|
||||
const Page = (await importLobbyPage()).default;
|
||||
const ui = render(Page);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ui.getAllByTestId("lobby-my-game-card").length).toBe(1);
|
||||
expect(ui.getByText("First Contact")).toBeInTheDocument();
|
||||
expect(ui.getByText("Open Lobby")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("submitting an application opens the inline form and posts race_name", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([]);
|
||||
listPublicGamesSpy.mockResolvedValue({
|
||||
items: [makePublicGame("public-1", "Open Lobby")],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1,
|
||||
});
|
||||
listMyInvitesSpy.mockResolvedValue([]);
|
||||
listMyApplicationsSpy.mockResolvedValue([]);
|
||||
submitApplicationSpy.mockResolvedValue(makeApplication("app-1", "pending"));
|
||||
|
||||
const Page = (await importLobbyPage()).default;
|
||||
const ui = render(Page);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ui.getByTestId("lobby-public-game-apply")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await fireEvent.click(ui.getByTestId("lobby-public-game-apply"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ui.getByTestId("lobby-application-form")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await fireEvent.input(ui.getByTestId("lobby-application-race-name"), {
|
||||
target: { value: "Vegan Federation" },
|
||||
});
|
||||
await fireEvent.click(ui.getByTestId("lobby-application-submit"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitApplicationSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"public-1",
|
||||
"Vegan Federation",
|
||||
);
|
||||
expect(ui.getByTestId("lobby-application-card")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("submitting an empty race name surfaces a validation error and does not call the API", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([]);
|
||||
listPublicGamesSpy.mockResolvedValue({
|
||||
items: [makePublicGame("public-1", "Open Lobby")],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1,
|
||||
});
|
||||
listMyInvitesSpy.mockResolvedValue([]);
|
||||
listMyApplicationsSpy.mockResolvedValue([]);
|
||||
|
||||
const Page = (await importLobbyPage()).default;
|
||||
const ui = render(Page);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(ui.getByTestId("lobby-public-game-apply")).toBeInTheDocument(),
|
||||
);
|
||||
await fireEvent.click(ui.getByTestId("lobby-public-game-apply"));
|
||||
await fireEvent.click(ui.getByTestId("lobby-application-submit"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ui.getByTestId("lobby-application-error")).toBeInTheDocument();
|
||||
expect(submitApplicationSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test("accepting an invitation calls redeemInvite and removes the card", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([]);
|
||||
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
listMyInvitesSpy.mockResolvedValue([makeInvite("invite-1")]);
|
||||
listMyApplicationsSpy.mockResolvedValue([]);
|
||||
redeemInviteSpy.mockResolvedValue(makeInvite("invite-1"));
|
||||
|
||||
const Page = (await importLobbyPage()).default;
|
||||
const ui = render(Page);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(ui.getByTestId("lobby-invite-accept")).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await fireEvent.click(ui.getByTestId("lobby-invite-accept"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(redeemInviteSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"private-1",
|
||||
"invite-1",
|
||||
);
|
||||
expect(ui.queryByTestId("lobby-invite-accept")).not.toBeInTheDocument();
|
||||
expect(ui.getByTestId("lobby-invitations-empty")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("declining an invitation calls declineInvite and removes the card", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([]);
|
||||
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
listMyInvitesSpy.mockResolvedValue([makeInvite("invite-2")]);
|
||||
listMyApplicationsSpy.mockResolvedValue([]);
|
||||
declineInviteSpy.mockResolvedValue({ ...makeInvite("invite-2"), status: "declined" });
|
||||
|
||||
const Page = (await importLobbyPage()).default;
|
||||
const ui = render(Page);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(ui.getByTestId("lobby-invite-decline")).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await fireEvent.click(ui.getByTestId("lobby-invite-decline"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(declineInviteSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"private-1",
|
||||
"invite-2",
|
||||
);
|
||||
expect(ui.queryByTestId("lobby-invite-decline")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("my-game cards are clickable for running/paused/finished and disabled otherwise", async () => {
|
||||
// Cover the live-able statuses (running, paused, finished) and a
|
||||
// representative non-playable mix (cancelled is the post-shutdown
|
||||
// terminal state developers see most often; draft is the lobby-
|
||||
// internal state before any membership exists).
|
||||
listMyGamesSpy.mockResolvedValue([
|
||||
makeGame("g-running", "Live", "running"),
|
||||
makeGame("g-paused", "Paused Run", "paused"),
|
||||
makeGame("g-finished", "Closed Run", "finished"),
|
||||
makeGame("g-cancelled", "Cancelled Run", "cancelled"),
|
||||
makeGame("g-draft", "Draft Run", "draft"),
|
||||
]);
|
||||
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
listMyInvitesSpy.mockResolvedValue([]);
|
||||
listMyApplicationsSpy.mockResolvedValue([]);
|
||||
|
||||
const Page = (await importLobbyPage()).default;
|
||||
const ui = render(Page);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ui.getAllByTestId("lobby-my-game-card").length).toBe(5);
|
||||
});
|
||||
const cards = ui.getAllByTestId("lobby-my-game-card");
|
||||
const disabledByLabel: Record<string, boolean> = {};
|
||||
for (const card of cards) {
|
||||
const label = card.querySelector("strong")?.textContent ?? "";
|
||||
disabledByLabel[label] = (card as HTMLButtonElement).disabled;
|
||||
}
|
||||
expect(disabledByLabel["Live"]).toBe(false);
|
||||
expect(disabledByLabel["Paused Run"]).toBe(false);
|
||||
expect(disabledByLabel["Closed Run"]).toBe(false);
|
||||
expect(disabledByLabel["Cancelled Run"]).toBe(true);
|
||||
expect(disabledByLabel["Draft Run"]).toBe(true);
|
||||
|
||||
// Clicking a playable card resets the in-game view and enters the
|
||||
// game screen with its id (the single-URL app-shell switches
|
||||
// in-memory state instead of navigating to `/games/:id`).
|
||||
const liveCard = cards.find(
|
||||
(card) => card.querySelector("strong")?.textContent === "Live",
|
||||
);
|
||||
await fireEvent.click(liveCard!);
|
||||
expect(activeViewResetSpy).toHaveBeenCalledTimes(1);
|
||||
expect(appScreenGoSpy).toHaveBeenCalledWith("game", {
|
||||
gameId: "g-running",
|
||||
});
|
||||
});
|
||||
|
||||
test("application status badges localise pending and approved states", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([]);
|
||||
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
listMyInvitesSpy.mockResolvedValue([]);
|
||||
listMyApplicationsSpy.mockResolvedValue([
|
||||
makeApplication("app-1", "pending"),
|
||||
makeApplication("app-2", "approved"),
|
||||
]);
|
||||
|
||||
const Page = (await importLobbyPage()).default;
|
||||
const ui = render(Page);
|
||||
|
||||
await waitFor(() => {
|
||||
const cards = ui.getAllByTestId("lobby-application-card");
|
||||
expect(cards.length).toBe(2);
|
||||
expect(cards[0]!.querySelector(".status")?.textContent?.trim()).toBe("pending");
|
||||
expect(cards[1]!.querySelector(".status")?.textContent?.trim()).toBe("approved");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user