import { expect, test, type Page } from "@playwright/test"; import { mockCoreApis } from "@e2e/fixtures/api-mocks"; import { apiEnvelope } from "@e2e/fixtures/data/common"; import { clearBrowserState, seedEmailSession, } from "@e2e/fixtures/test-helpers"; const postId = "11111111-1111-1111-1111-111111111111"; let unlocked = false; test.beforeEach(async ({ baseURL, context, page }) => { unlocked = false; await clearBrowserState(context, page, baseURL); await mockCoreApis(page); await registerPrivateZoneVideoMocks(page); }); test("Private Zone defaults to Moments and reveals video only after one credit unlock", async ({ page, }) => { await seedEmailSession(page); const listRequest = page.waitForRequest((request) => { const url = new URL(request.url()); return ( url.pathname === "/api/private-zone/posts" && url.searchParams.get("characterId") === "maya-tan" ); }); await page.goto("/characters/maya/private-zone"); await listRequest; await expect( page.getByRole("heading", { name: "Private moments" }), ).toBeVisible(); await expect(page.getByText("Only for you.")).toBeVisible(); await expect(page.locator("video")).toHaveCount(0); await page.getByRole("button", { name: "Unlock for 100 credits" }).click(); const dialog = page.getByRole("alertdialog"); await expect(dialog).toContainText("100 credits"); const unlockRequest = page.waitForRequest( `**/api/private-zone/posts/${postId}/unlock`, ); await dialog.getByRole("button", { name: "Unlock", exact: true }).click(); expect((await unlockRequest).postDataJSON()).toEqual({ expectedCost: 100 }); const video = page.locator("video"); await expect(video).toHaveCount(1); await expect(video.locator("source")).toHaveAttribute("src", /video\.mp4/); await expect(page.getByRole("alertdialog")).toHaveCount(0); }); test("Albums remain available behind the second tab", async ({ page }) => { await seedEmailSession(page); await page.goto("/characters/maya/private-zone"); await page.getByRole("tab", { name: "Albums" }).click(); await expect( page.getByRole("heading", { name: "Private albums" }), ).toBeVisible(); await expect(page.getByText("Maya archive")).toBeVisible(); }); async function registerPrivateZoneVideoMocks(page: Page): Promise { await page.route("**/api/private-zone/posts**", async (route) => { const url = new URL(route.request().url()); await route.fulfill({ json: apiEnvelope({ characterId: url.searchParams.get("characterId") ?? "maya-tan", items: [videoPost(unlocked)], nextCursor: null, hasMore: false, creditBalance: unlocked ? 150 : 250, currency: "credits", }), }); }); // Playwright evaluates page routes in reverse registration order. Register // the specific mutation last so the broader list route cannot intercept it. await page.route("**/api/private-zone/posts/*/unlock", async (route) => { unlocked = true; await route.fulfill({ json: apiEnvelope({ postId, reason: "ok", unlocked: true, creditsCharged: 100, requiredCredits: 100, currentCredits: 150, shortfallCredits: 0, creditBalance: 150, post: videoPost(true), }), }); }); await page.route("**/api/private-zone/albums**", async (route) => { await route.fulfill({ json: apiEnvelope({ items: [ { albumId: "album-maya", title: "Maya archive", content: null, previewText: "Private photos", imageCount: 1, images: [{ url: "/images/private-zone/banner/maya.png", locked: true, index: 0 }], locked: true, unlocked: false, unlockCost: 80, publishedAt: "2026-07-24T10:00:00+00:00", lockDetail: { locked: true }, }, ], creditBalance: 250, pendingImageCount: 0, hasIncompleteContent: false, }), }); }); } function videoPost(isUnlocked: boolean) { return { postId, characterId: "maya-tan", caption: "Only for you.", posterUrl: "/images/private-zone/banner/maya.png", mediaType: "video", videoUrl: isUnlocked ? "/test-video.mp4?token=short" : null, videoMimeType: "video/mp4", videoSizeBytes: 1024, durationSeconds: 18, unlockCostCredits: 100, currency: "credits", locked: !isUnlocked, unlocked: isUnlocked, availableForNewUnlock: true, publishedAt: "2026-07-24T10:00:00+00:00", }; }