diff --git a/e2e/fixtures/api-mocks.ts b/e2e/fixtures/api-mocks.ts index 149e2c91..3ef0f862 100644 --- a/e2e/fixtures/api-mocks.ts +++ b/e2e/fixtures/api-mocks.ts @@ -11,8 +11,10 @@ import { emptyChatHistoryResponse, emailLoginResponse, guestLoginResponse, + insufficientCreditsUnlockVoiceResponse, paidImageChatSendResponse, paidImageUnlockHistoryResponse, + paidVoiceHistoryResponse, paidPaymentOrderStatusResponse, paymentPlansResponse, userEntitlementsResponse, @@ -22,13 +24,18 @@ import { interface MockCoreApisOptions { chatLimitTriggerAt?: number; paidImageFlow?: boolean; + paidVoiceInsufficientCreditsFlow?: boolean; } export async function mockCoreApis( page: Page, options: MockCoreApisOptions = {}, ): Promise { - const { chatLimitTriggerAt, paidImageFlow = false } = options; + const { + chatLimitTriggerAt, + paidImageFlow = false, + paidVoiceInsufficientCreditsFlow = false, + } = options; let sendCount = 0; let paidImageRequested = false; let paidImageUnlocked = false; @@ -59,7 +66,9 @@ export async function mockCoreApis( await page.route("**/api/chat/history**", async (route) => { const response = - paidImageFlow && paidImageRequested + paidVoiceInsufficientCreditsFlow + ? paidVoiceHistoryResponse + : paidImageFlow && paidImageRequested ? createPaidImageHistoryResponse(paidImageUnlocked) : emptyChatHistoryResponse; @@ -118,6 +127,30 @@ export async function mockCoreApis( await route.fulfill({ json: apiEnvelope(response) }); }); + await page.route("**/api/chat/unlock-private", async (route) => { + const response = paidVoiceInsufficientCreditsFlow + ? insufficientCreditsUnlockVoiceResponse + : { + unlocked: true, + content: "Unlocked message", + reason: "ok", + creditBalance: 100, + creditsCharged: 0, + requiredCredits: 0, + shortfallCredits: 0, + lockDetail: { + locked: false, + showContent: true, + showUpgrade: false, + reason: null, + hint: null, + detail: null, + }, + }; + + await route.fulfill({ json: apiEnvelope(response) }); + }); + await page.route("**/api/payment/plans**", async (route) => { await route.fulfill({ json: apiEnvelope(paymentPlansResponse) }); }); diff --git a/e2e/fixtures/test-data.ts b/e2e/fixtures/test-data.ts index 04b7920e..0dadb344 100644 --- a/e2e/fixtures/test-data.ts +++ b/e2e/fixtures/test-data.ts @@ -207,6 +207,60 @@ export const paidImageUnlockHistoryResponse = { messageIds: [paidImageMessageId], }; +export const paidVoiceMessageId = "msg_voice_paywall_001"; +export const paidVoiceAudioUrl = + "data:audio/mpeg;base64,//uQZAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAACcQCA"; + +export const paidVoiceHistoryResponse = { + messages: [ + { + role: "assistant", + type: "voice", + content: "", + id: paidVoiceMessageId, + created_at: "2026-07-01T00:00:00.000Z", + audioUrl: paidVoiceAudioUrl, + image: { + type: null, + url: null, + }, + lockDetail: { + locked: true, + showContent: true, + showUpgrade: true, + reason: "voice_message", + hint: "Unlock this voice message with credits.", + detail: null, + }, + }, + ], + total: 1, + limit: 50, + offset: 0, + isVip: false, + privateFreeLimit: 0, + privateUsedToday: 0, + privateCanViewFree: false, +}; + +export const insufficientCreditsUnlockVoiceResponse = { + unlocked: false, + content: "", + reason: "insufficient_balance", + creditBalance: 3, + creditsCharged: 0, + requiredCredits: 20, + shortfallCredits: 17, + lockDetail: { + locked: true, + showContent: true, + showUpgrade: true, + reason: "voice_message", + hint: "Unlock this voice message with credits.", + detail: null, + }, +}; + export const paymentPlansResponse = { plans: [ { diff --git a/e2e/specs/mock/voice-unlock-insufficient-credits.spec.ts b/e2e/specs/mock/voice-unlock-insufficient-credits.spec.ts new file mode 100644 index 00000000..f0786dfb --- /dev/null +++ b/e2e/specs/mock/voice-unlock-insufficient-credits.spec.ts @@ -0,0 +1,83 @@ +import { expect, test } from "@playwright/test"; + +import { mockCoreApis } from "../../fixtures/api-mocks"; +import { paidVoiceMessageId } from "../../fixtures/test-data"; + +test.beforeEach(async ({ baseURL, context, page }) => { + await context.clearCookies(); + + const client = await context.newCDPSession(page); + const origins = new Set([ + new URL(baseURL ?? "http://127.0.0.1:3000").origin, + "http://127.0.0.1:3000", + "http://localhost:3000", + ]); + for (const origin of origins) { + await client + .send("Storage.clearDataForOrigin", { + origin, + storageTypes: "all", + }) + .catch(() => {}); + } + + await mockCoreApis(page, { + paidVoiceInsufficientCreditsFlow: true, + }); +}); + +test("user sees insufficient credits when unlocking a paid voice message and can navigate to subscription", async ({ + page, +}) => { + await signInWithEmailAndOpenChat(page); + + const unlockButton = page.getByRole("button", { + name: "Unlock voice message", + }); + await expect(unlockButton).toBeVisible({ timeout: 10_000 }); + + const unlockRequestPromise = page.waitForRequest( + "**/api/chat/unlock-private", + ); + await unlockButton.click(); + + const unlockRequest = await unlockRequestPromise; + expect(unlockRequest.method()).toBe("POST"); + expect(unlockRequest.postDataJSON()).toMatchObject({ + messageId: paidVoiceMessageId, + }); + + const insufficientCreditsDialog = page.getByRole("dialog", { + name: "Not enough credits", + }); + await expect(insufficientCreditsDialog).toBeVisible(); + await expect(insufficientCreditsDialog.getByText("Balance")).toBeVisible(); + await expect(insufficientCreditsDialog.getByText("Required")).toBeVisible(); + await expect(insufficientCreditsDialog.getByText("Still needed")).toBeVisible(); + + await insufficientCreditsDialog + .getByRole("button", { name: "Top up now" }) + .click(); + + await expect(page).toHaveURL(/\/subscription\?type=vip&returnTo=chat$/); +}); + +async function signInWithEmailAndOpenChat( + page: import("@playwright/test").Page, +) { + await page.goto("/auth"); + + await page.getByRole("button", { name: /Other Sign In Options/i }).click(); + await expect( + page.getByRole("dialog", { name: /Other sign-in options/i }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Email Sign In" }).click(); + await expect(page.getByPlaceholder("Email")).toHaveValue( + "chanwillian0@gmail.com", + ); + await expect(page.getByPlaceholder("Password")).toHaveValue("12345678"); + + await page.getByRole("button", { name: "Login" }).click(); + await expect(page).toHaveURL(/\/chat$/); +}