feat(private-zone): add paid video moments
This commit is contained in:
@@ -13,6 +13,7 @@ const characters = [
|
||||
displayName: "Elio Silvestri",
|
||||
shortName: "Elio",
|
||||
cover: "elio.png",
|
||||
splashCover: "elio.png",
|
||||
},
|
||||
{
|
||||
id: "maya-tan",
|
||||
@@ -20,6 +21,7 @@ const characters = [
|
||||
displayName: "Maya Tan",
|
||||
shortName: "Maya",
|
||||
cover: "maya.webp",
|
||||
splashCover: "maya-home.webp",
|
||||
},
|
||||
{
|
||||
id: "nayeli-cervantes",
|
||||
@@ -27,6 +29,7 @@ const characters = [
|
||||
displayName: "Nayeli Cervantes",
|
||||
shortName: "Nayeli",
|
||||
cover: "nayeli.webp",
|
||||
splashCover: "nayeli.webp",
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -56,9 +59,11 @@ for (const character of characters) {
|
||||
|
||||
await page.goto(`/characters/${character.slug}/splash`);
|
||||
|
||||
const cover = page.locator(`img[src*="${character.cover}"]`);
|
||||
const cover = page.locator(`img[src*="${character.splashCover}"]`);
|
||||
await expect(cover).toBeVisible();
|
||||
await expect(page.getByText(character.displayName).last()).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("navigation", { name: "Primary navigation" }),
|
||||
).toContainText("Private Zone");
|
||||
await expect
|
||||
.poll(() =>
|
||||
cover.evaluate((image: HTMLImageElement) =>
|
||||
@@ -84,6 +89,13 @@ for (const character of characters) {
|
||||
test(`${character.displayName} can open a character-scoped Private Zone`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const momentsRequest = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url());
|
||||
return (
|
||||
url.pathname === "/api/private-zone/posts" &&
|
||||
url.searchParams.get("characterId") === character.id
|
||||
);
|
||||
});
|
||||
const albumRequest = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url());
|
||||
return (
|
||||
@@ -94,7 +106,11 @@ for (const character of characters) {
|
||||
|
||||
await page.goto(`/characters/${character.slug}/private-zone`);
|
||||
|
||||
await albumRequest;
|
||||
await Promise.all([momentsRequest, albumRequest]);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Private moments" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("tab", { name: "Albums" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Private albums" }),
|
||||
).toBeVisible();
|
||||
@@ -147,11 +163,27 @@ test("a real Private Zone request failure still offers Retry", async ({
|
||||
|
||||
await page.goto("/characters/maya/private-zone");
|
||||
|
||||
await page.getByRole("tab", { name: "Albums" }).click();
|
||||
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
async function registerMultiRoleCommercialMocks(page: Page): Promise<void> {
|
||||
await page.route("**/api/private-zone/posts**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const characterId = url.searchParams.get("characterId") ?? "elio";
|
||||
await route.fulfill({
|
||||
json: apiEnvelope({
|
||||
characterId,
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
creditBalance: 1000,
|
||||
currency: "credits",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/private-zone/albums**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const characterId = url.searchParams.get("characterId") ?? "elio";
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
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<void> {
|
||||
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",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user