refactor(e2e): organize fixtures by feature
This commit is contained in:
+20
-199
@@ -1,31 +1,11 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import {
|
||||
apiEnvelope,
|
||||
chatSendResponse,
|
||||
createPaymentOrderResponse,
|
||||
createChatSendResponse,
|
||||
createPaidImageHistoryResponse,
|
||||
createWeeklyLimitChatSendResponse,
|
||||
e2eEmailUser,
|
||||
emptyChatHistoryResponse,
|
||||
emailLoginResponse,
|
||||
guestLoginResponse,
|
||||
insufficientCreditsUnlockImageResponse,
|
||||
insufficientCreditsUnlockVoiceResponse,
|
||||
paidImageChatSendResponse,
|
||||
paidImageUnlockHistoryResponse,
|
||||
paidVoiceHistoryResponse,
|
||||
paidPaymentOrderStatusResponse,
|
||||
paymentPlansResponse,
|
||||
tipPaymentPlansResponse,
|
||||
refreshedEmailLoginResponse,
|
||||
refreshedGuestLoginResponse,
|
||||
userEntitlementsResponse,
|
||||
vipStatusResponse,
|
||||
} from "./test-data";
|
||||
import { registerAuthMocks } from "./api/auth";
|
||||
import { registerChatMocks } from "./api/chat";
|
||||
import { registerPaymentMocks } from "./api/payment";
|
||||
import { registerUserMocks } from "./api/user";
|
||||
|
||||
interface MockCoreApisOptions {
|
||||
export interface MockCoreApisOptions {
|
||||
chatLimitTriggerAt?: number;
|
||||
chatSendTokenRefreshFlow?: boolean;
|
||||
paidImageFlow?: boolean;
|
||||
@@ -33,180 +13,21 @@ interface MockCoreApisOptions {
|
||||
paidVoiceInsufficientCreditsFlow?: boolean;
|
||||
}
|
||||
|
||||
export async function mockCoreApis(
|
||||
page: Page,
|
||||
options: MockCoreApisOptions = {},
|
||||
): Promise<void> {
|
||||
const {
|
||||
chatLimitTriggerAt,
|
||||
chatSendTokenRefreshFlow = false,
|
||||
paidImageFlow = false,
|
||||
paidImageInsufficientCreditsFlow = false,
|
||||
paidVoiceInsufficientCreditsFlow = false,
|
||||
} = options;
|
||||
let sendCount = 0;
|
||||
let hasExpiredChatSend = false;
|
||||
let paidImageRequested = false;
|
||||
let paidImageUnlocked = false;
|
||||
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
|
||||
const chatOptions = {
|
||||
chatLimitTriggerAt: options.chatLimitTriggerAt,
|
||||
chatSendTokenRefreshFlow: options.chatSendTokenRefreshFlow ?? false,
|
||||
paidImageFlow: options.paidImageFlow ?? false,
|
||||
paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false,
|
||||
paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false,
|
||||
};
|
||||
const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
|
||||
|
||||
await page.route("**/api/auth/guest", async (route) => {
|
||||
const response =
|
||||
chatSendTokenRefreshFlow && hasExpiredChatSend
|
||||
? refreshedGuestLoginResponse
|
||||
: guestLoginResponse;
|
||||
await route.fulfill({ json: apiEnvelope(response) });
|
||||
});
|
||||
|
||||
await page.route("**/api/auth/login", async (route) => {
|
||||
if (paidImageFlow) {
|
||||
paidImageRequested = true;
|
||||
}
|
||||
|
||||
await route.fulfill({ json: apiEnvelope(emailLoginResponse) });
|
||||
});
|
||||
|
||||
await page.route("**/api/auth/refresh", async (route) => {
|
||||
await route.fulfill({ json: apiEnvelope(refreshedEmailLoginResponse) });
|
||||
});
|
||||
|
||||
await page.route("**/api/auth/logout", async (route) => {
|
||||
await route.fulfill({ json: apiEnvelope(null) });
|
||||
});
|
||||
|
||||
await page.route("**/api/user/profile", async (route) => {
|
||||
await route.fulfill({ json: apiEnvelope(e2eEmailUser) });
|
||||
});
|
||||
|
||||
await page.route("**/api/user/entitlements", async (route) => {
|
||||
await route.fulfill({ json: apiEnvelope(userEntitlementsResponse) });
|
||||
});
|
||||
|
||||
await page.route("**/api/chat/history**", async (route) => {
|
||||
const response =
|
||||
paidVoiceInsufficientCreditsFlow
|
||||
? paidVoiceHistoryResponse
|
||||
: paidImageInsufficientCreditsFlow
|
||||
? createPaidImageHistoryResponse(false)
|
||||
: paidImageFlow && paidImageRequested
|
||||
? createPaidImageHistoryResponse(paidImageUnlocked)
|
||||
: emptyChatHistoryResponse;
|
||||
|
||||
await route.fulfill({ json: apiEnvelope(response) });
|
||||
});
|
||||
|
||||
await page.route("**/api/chat/send", async (route) => {
|
||||
sendCount += 1;
|
||||
const requestBody = route.request().postDataJSON() as
|
||||
| { message?: unknown }
|
||||
| undefined;
|
||||
const message =
|
||||
typeof requestBody?.message === "string" ? requestBody.message : "";
|
||||
|
||||
if (chatSendTokenRefreshFlow && !hasExpiredChatSend) {
|
||||
hasExpiredChatSend = true;
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
json: {
|
||||
message: "expired",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response =
|
||||
paidImageFlow && message.includes("给我发图片")
|
||||
? paidImageChatSendResponse
|
||||
: chatLimitTriggerAt != null && sendCount >= chatLimitTriggerAt
|
||||
? createWeeklyLimitChatSendResponse(sendCount, chatLimitTriggerAt)
|
||||
: chatLimitTriggerAt != null
|
||||
? createChatSendResponse(sendCount)
|
||||
: chatSendResponse;
|
||||
|
||||
if (paidImageFlow && message.includes("给我发图片")) {
|
||||
paidImageRequested = true;
|
||||
paidImageUnlocked = false;
|
||||
}
|
||||
|
||||
await route.fulfill({ json: apiEnvelope(response) });
|
||||
});
|
||||
|
||||
await page.route("**/api/chat/unlock-history", async (route) => {
|
||||
if (paidImageFlow && paidImageRequested) {
|
||||
paidImageUnlocked = true;
|
||||
}
|
||||
|
||||
const response =
|
||||
paidImageFlow && paidImageRequested
|
||||
? paidImageUnlockHistoryResponse
|
||||
: {
|
||||
unlocked: true,
|
||||
reason: "no_locked_messages",
|
||||
totalLocked: 0,
|
||||
unlockedCount: 0,
|
||||
privateCount: 0,
|
||||
imageCount: 0,
|
||||
voiceCount: 0,
|
||||
requiredCredits: 0,
|
||||
currentCredits: 0,
|
||||
remainingCredits: 0,
|
||||
shortfallCredits: 0,
|
||||
costsByMessage: {},
|
||||
messageIds: [],
|
||||
};
|
||||
|
||||
await route.fulfill({ json: apiEnvelope(response) });
|
||||
});
|
||||
|
||||
await page.route("**/api/chat/unlock-private", async (route) => {
|
||||
const response =
|
||||
paidVoiceInsufficientCreditsFlow
|
||||
? insufficientCreditsUnlockVoiceResponse
|
||||
: paidImageInsufficientCreditsFlow
|
||||
? insufficientCreditsUnlockImageResponse
|
||||
: paidImageFlow && paidImageRequested && !paidImageUnlocked
|
||||
? insufficientCreditsUnlockImageResponse
|
||||
: {
|
||||
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) });
|
||||
});
|
||||
|
||||
await page.route("**/api/payment/tip-plans**", async (route) => {
|
||||
await route.fulfill({ json: apiEnvelope(tipPaymentPlansResponse) });
|
||||
});
|
||||
|
||||
await page.route("**/api/payment/create-order", async (route) => {
|
||||
await route.fulfill({ json: apiEnvelope(createPaymentOrderResponse) });
|
||||
});
|
||||
|
||||
await page.route("**/api/payment/order-status**", async (route) => {
|
||||
if (paidImageFlow && paidImageRequested) {
|
||||
paidImageUnlocked = true;
|
||||
}
|
||||
|
||||
await route.fulfill({ json: apiEnvelope(paidPaymentOrderStatusResponse) });
|
||||
});
|
||||
|
||||
await page.route("**/api/payment/vip-status**", async (route) => {
|
||||
await route.fulfill({ json: apiEnvelope(vipStatusResponse) });
|
||||
await registerAuthMocks(page, {
|
||||
chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
|
||||
isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
|
||||
});
|
||||
await registerUserMocks(page);
|
||||
await registerChatMocks(page, chatOptions, chatState);
|
||||
await registerPaymentMocks(page);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { apiEnvelope } from "../data/common";
|
||||
import { emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse } from "../data/auth";
|
||||
|
||||
export interface AuthMockState {
|
||||
chatSendTokenRefreshFlow: boolean;
|
||||
isChatSendTokenExpired: () => boolean;
|
||||
}
|
||||
|
||||
export async function registerAuthMocks(page: Page, state: AuthMockState) {
|
||||
await page.route("**/api/auth/guest", async (route) => {
|
||||
const response = state.chatSendTokenRefreshFlow && state.isChatSendTokenExpired() ? refreshedGuestLoginResponse : guestLoginResponse;
|
||||
await route.fulfill({ json: apiEnvelope(response) });
|
||||
});
|
||||
await page.route("**/api/auth/login", async (route) => {
|
||||
await route.fulfill({ json: apiEnvelope(emailLoginResponse) });
|
||||
});
|
||||
await page.route("**/api/auth/refresh", async (route) => {
|
||||
await route.fulfill({ json: apiEnvelope(refreshedEmailLoginResponse) });
|
||||
});
|
||||
await page.route("**/api/auth/logout", async (route) => {
|
||||
await route.fulfill({ json: apiEnvelope(null) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { apiEnvelope } from "../data/common";
|
||||
import { chatSendResponse, createChatSendResponse, createPaidImageHistoryResponse, createWeeklyLimitChatSendResponse, emptyChatHistoryResponse, insufficientCreditsUnlockImageResponse, insufficientCreditsUnlockVoiceResponse, paidImageChatSendResponse, paidImageUnlockHistoryResponse, paidVoiceHistoryResponse } from "../data/chat";
|
||||
|
||||
export interface ChatMockOptions {
|
||||
chatLimitTriggerAt?: number;
|
||||
chatSendTokenRefreshFlow: boolean;
|
||||
paidImageFlow: boolean;
|
||||
paidImageInsufficientCreditsFlow: boolean;
|
||||
paidVoiceInsufficientCreditsFlow: boolean;
|
||||
}
|
||||
|
||||
export interface ChatMockState {
|
||||
hasExpiredChatSend: boolean;
|
||||
paidImageRequested: boolean;
|
||||
paidImageUnlocked: boolean;
|
||||
}
|
||||
|
||||
export async function registerChatMocks(page: Page, options: ChatMockOptions, state: ChatMockState) {
|
||||
let sendCount = 0;
|
||||
|
||||
await page.route("**/api/chat/history**", async (route) => {
|
||||
const response = options.paidVoiceInsufficientCreditsFlow
|
||||
? paidVoiceHistoryResponse
|
||||
: options.paidImageInsufficientCreditsFlow
|
||||
? createPaidImageHistoryResponse(false)
|
||||
: options.paidImageFlow && state.paidImageRequested
|
||||
? createPaidImageHistoryResponse(state.paidImageUnlocked)
|
||||
: emptyChatHistoryResponse;
|
||||
await route.fulfill({ json: apiEnvelope(response) });
|
||||
});
|
||||
|
||||
await page.route("**/api/chat/send", async (route) => {
|
||||
sendCount += 1;
|
||||
const requestBody = route.request().postDataJSON() as { message?: unknown } | undefined;
|
||||
const message = typeof requestBody?.message === "string" ? requestBody.message : "";
|
||||
if (options.chatSendTokenRefreshFlow && !state.hasExpiredChatSend) {
|
||||
state.hasExpiredChatSend = true;
|
||||
await route.fulfill({ status: 401, json: { message: "expired" } });
|
||||
return;
|
||||
}
|
||||
const response = options.paidImageFlow && message.includes("给我发图片")
|
||||
? paidImageChatSendResponse
|
||||
: options.chatLimitTriggerAt != null && sendCount >= options.chatLimitTriggerAt
|
||||
? createWeeklyLimitChatSendResponse(sendCount, options.chatLimitTriggerAt)
|
||||
: options.chatLimitTriggerAt != null
|
||||
? createChatSendResponse(sendCount)
|
||||
: chatSendResponse;
|
||||
if (options.paidImageFlow && message.includes("给我发图片")) {
|
||||
state.paidImageRequested = true;
|
||||
state.paidImageUnlocked = false;
|
||||
}
|
||||
await route.fulfill({ json: apiEnvelope(response) });
|
||||
});
|
||||
|
||||
await page.route("**/api/chat/unlock-history", async (route) => {
|
||||
if (options.paidImageFlow && state.paidImageRequested) state.paidImageUnlocked = true;
|
||||
const response = options.paidImageFlow && state.paidImageRequested ? paidImageUnlockHistoryResponse : { unlocked: true, reason: "no_locked_messages", totalLocked: 0, unlockedCount: 0, privateCount: 0, imageCount: 0, voiceCount: 0, requiredCredits: 0, currentCredits: 0, remainingCredits: 0, shortfallCredits: 0, costsByMessage: {}, messageIds: [] };
|
||||
await route.fulfill({ json: apiEnvelope(response) });
|
||||
});
|
||||
|
||||
await page.route("**/api/chat/unlock-private", async (route) => {
|
||||
const response = options.paidVoiceInsufficientCreditsFlow
|
||||
? insufficientCreditsUnlockVoiceResponse
|
||||
: options.paidImageInsufficientCreditsFlow || options.paidImageFlow && state.paidImageRequested && !state.paidImageUnlocked
|
||||
? insufficientCreditsUnlockImageResponse
|
||||
: { 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) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { apiEnvelope } from "../data/common";
|
||||
import { createPaymentOrderResponse, paidPaymentOrderStatusResponse, paymentPlansResponse, tipPaymentPlansResponse, vipStatusResponse } from "../data/payment";
|
||||
|
||||
export async function registerPaymentMocks(page: Page) {
|
||||
await page.route("**/api/payment/plans**", async (route) => route.fulfill({ json: apiEnvelope(paymentPlansResponse) }));
|
||||
await page.route("**/api/payment/tip-plans**", async (route) => route.fulfill({ json: apiEnvelope(tipPaymentPlansResponse) }));
|
||||
await page.route("**/api/payment/create-order", async (route) => route.fulfill({ json: apiEnvelope(createPaymentOrderResponse) }));
|
||||
await page.route("**/api/payment/order-status**", async (route) => route.fulfill({ json: apiEnvelope(paidPaymentOrderStatusResponse) }));
|
||||
await page.route("**/api/payment/vip-status", async (route) => route.fulfill({ json: apiEnvelope(vipStatusResponse) }));
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { apiEnvelope } from "../data/common";
|
||||
import { e2eEmailUser } from "../data/auth";
|
||||
import { userEntitlementsResponse } from "../data/user";
|
||||
|
||||
export async function registerUserMocks(page: Page) {
|
||||
await page.route("**/api/user/profile", async (route) => {
|
||||
await route.fulfill({ json: apiEnvelope(e2eEmailUser) });
|
||||
});
|
||||
await page.route("**/api/user/entitlements", async (route) => {
|
||||
await route.fulfill({ json: apiEnvelope(userEntitlementsResponse) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export const e2eUser = {
|
||||
id: "user_e2e",
|
||||
username: "E2E User",
|
||||
email: "e2e@example.com",
|
||||
platform: "web",
|
||||
intimacy: 42,
|
||||
dolBalance: 0,
|
||||
relationshipStage: "close_friend",
|
||||
currentMood: "happy",
|
||||
preferredLanguage: "en",
|
||||
createdAt: "2026-06-25T00:00:00.000Z",
|
||||
lastMessageAt: "",
|
||||
avatarUrl: "",
|
||||
loginProvider: "guest",
|
||||
isGuest: true,
|
||||
isVip: false,
|
||||
voiceMinutesRemaining: 0,
|
||||
};
|
||||
|
||||
export const e2eEmailUser = {
|
||||
...e2eUser,
|
||||
id: "user_e2e_email",
|
||||
username: "E2E Email User",
|
||||
email: "chanwillian0@gmail.com",
|
||||
isGuest: false,
|
||||
};
|
||||
|
||||
export const guestLoginResponse = {
|
||||
token: "e2e-guest-token",
|
||||
deviceId: "e2e-device-id",
|
||||
userId: e2eUser.id,
|
||||
isDeviceUser: true,
|
||||
expiresIn: 2_592_000,
|
||||
user: e2eUser,
|
||||
};
|
||||
|
||||
export const refreshedGuestLoginResponse = {
|
||||
...guestLoginResponse,
|
||||
token: "e2e-refreshed-guest-token",
|
||||
};
|
||||
|
||||
export const emailLoginResponse = {
|
||||
token: "e2e-email-token",
|
||||
refreshToken: "e2e-email-refresh-token",
|
||||
user: e2eEmailUser,
|
||||
};
|
||||
|
||||
export const refreshedEmailLoginResponse = {
|
||||
token: "e2e-refreshed-email-token",
|
||||
refreshToken: "e2e-refreshed-email-refresh-token",
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
export const emptyChatHistoryResponse = {
|
||||
messages: [],
|
||||
total: 0,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
isVip: false,
|
||||
privateFreeLimit: 0,
|
||||
privateUsedToday: 0,
|
||||
privateCanViewFree: false,
|
||||
};
|
||||
|
||||
export const chatSendResponse = {
|
||||
reply: "Hello from the E2E boyfriend.",
|
||||
audioUrl: "",
|
||||
messageId: "msg_e2e_reply_001",
|
||||
isGuest: true,
|
||||
timestamp: 1_782_356_425_363,
|
||||
image: { type: null, url: null },
|
||||
lockDetail: {
|
||||
locked: false,
|
||||
showContent: true,
|
||||
showUpgrade: false,
|
||||
reason: null,
|
||||
hint: null,
|
||||
detail: null,
|
||||
},
|
||||
};
|
||||
|
||||
export function createChatSendResponse(sendCount: number) {
|
||||
return {
|
||||
...chatSendResponse,
|
||||
reply: `Hello from the E2E boyfriend. Reply ${sendCount}.`,
|
||||
messageId: `msg_e2e_reply_${String(sendCount).padStart(3, "0")}`,
|
||||
timestamp: chatSendResponse.timestamp + sendCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWeeklyLimitChatSendResponse(
|
||||
usedMessageCount: number,
|
||||
limit: number,
|
||||
) {
|
||||
return {
|
||||
reply: "",
|
||||
audioUrl: "",
|
||||
messageId: "",
|
||||
isGuest: true,
|
||||
canSendMessage: false,
|
||||
cannotSendReason: "insufficient_credits",
|
||||
creditBalance: 0,
|
||||
creditsCharged: 0,
|
||||
requiredCredits: limit,
|
||||
shortfallCredits: Math.max(0, limit - usedMessageCount),
|
||||
timestamp: chatSendResponse.timestamp + usedMessageCount,
|
||||
image: { type: null, url: null },
|
||||
lockDetail: {
|
||||
locked: true,
|
||||
showContent: false,
|
||||
showUpgrade: true,
|
||||
reason: "weekly_limit",
|
||||
hint: "Free message limit reached.",
|
||||
detail: { type: "weekly_msg_limit", usedThisWeek: usedMessageCount, limit },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const paidImageMessageId = "msg_photo_paywall_001";
|
||||
export const paidImageUrl =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||
export const paidImageChatSendResponse = {
|
||||
reply: "I can show you that photo after you activate VIP.",
|
||||
audioUrl: "",
|
||||
messageId: paidImageMessageId,
|
||||
isGuest: true,
|
||||
timestamp: 1_782_180_725_000,
|
||||
image: { type: "elio_schedule", url: paidImageUrl },
|
||||
lockDetail: { locked: true, showContent: true, showUpgrade: true, reason: "image", hint: "Activate VIP to unlock the full photo.", detail: null },
|
||||
};
|
||||
|
||||
export function createPaidImageHistoryResponse(unlocked: boolean) {
|
||||
return {
|
||||
messages: [{ role: "assistant", type: "image", content: unlocked ? "" : paidImageChatSendResponse.reply, id: paidImageMessageId, created_at: "2026-06-30T00:00:00.000Z", audioUrl: null, image: { type: "elio_schedule", url: paidImageUrl }, lockDetail: unlocked ? { locked: false, showContent: true, showUpgrade: false, reason: null, hint: null, detail: null } : paidImageChatSendResponse.lockDetail }],
|
||||
total: 1, limit: 50, offset: 0, isVip: unlocked, privateFreeLimit: 0, privateUsedToday: 0, privateCanViewFree: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const paidImageUnlockHistoryResponse = {
|
||||
unlocked: true, reason: "ok", totalLocked: 1, unlockedCount: 1, privateCount: 0, imageCount: 1, voiceCount: 0,
|
||||
requiredCredits: 0, currentCredits: 0, remainingCredits: 0, shortfallCredits: 0,
|
||||
costsByMessage: { [paidImageMessageId]: 0 }, 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: paidVoiceHistoryResponse.messages[0].lockDetail,
|
||||
};
|
||||
export const insufficientCreditsUnlockImageResponse = {
|
||||
unlocked: false, content: "", reason: "insufficient_balance", creditBalance: 3, creditsCharged: 0, requiredCredits: 30, shortfallCredits: 27,
|
||||
lockDetail: { locked: true, showContent: true, showUpgrade: true, reason: "image", hint: "Unlock this high-definition image with credits.", detail: null },
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export function apiEnvelope<T>(data: T) {
|
||||
return {
|
||||
code: 200,
|
||||
message: "success",
|
||||
success: true,
|
||||
data,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const paymentPlansResponse = { plans: [
|
||||
{ planId: "vip_monthly", planName: "Monthly", orderType: "vip_monthly", amountCents: 999, originalAmountCents: 1999, dailyPriceCents: 33, currency: "USD", vipDays: 30, dolAmount: null, creditBalance: 300 },
|
||||
{ planId: "vip_quarterly", planName: "Quarterly", orderType: "vip_quarterly", amountCents: 2499, originalAmountCents: 5999, dailyPriceCents: 28, currency: "USD", vipDays: 90, dolAmount: null, creditBalance: 1000 },
|
||||
{ planId: "dol_voice_30", planName: "Voice Pack", orderType: "dol_voice", amountCents: 499, originalAmountCents: null, dailyPriceCents: null, currency: "USD", vipDays: null, dolAmount: 30, creditBalance: 30 },
|
||||
] };
|
||||
export const tipPaymentPlansResponse = { plans: [
|
||||
{ planId: "tip_coffee_usd_4_99", planName: "Small Coffee", orderType: "tip", tipType: "coffee_small", description: "Buy Elio a small coffee", amountCents: 499, currency: "USD", autoRenew: false, isFirstRechargeOffer: false, firstRechargeDiscountPercent: 0 },
|
||||
{ planId: "tip_coffee_usd_9_99", planName: "Medium Coffee", orderType: "tip", tipType: "coffee_medium", description: "Buy Elio a medium coffee", amountCents: 999, currency: "USD", autoRenew: false, isFirstRechargeOffer: false, firstRechargeDiscountPercent: 0 },
|
||||
{ planId: "tip_coffee_usd_19_99", planName: "Large Coffee", orderType: "tip", tipType: "coffee_large", description: "Buy Elio a large coffee", amountCents: 1999, currency: "USD", autoRenew: false, isFirstRechargeOffer: false, firstRechargeDiscountPercent: 0 },
|
||||
] };
|
||||
export const paymentOrderId = "order_e2e_vip_monthly";
|
||||
export const createPaymentOrderResponse = { orderId: paymentOrderId, payParams: { provider: "stripe", clientSecret: "pi_e2e_secret_mock" } };
|
||||
export const paidPaymentOrderStatusResponse = { orderId: paymentOrderId, status: "paid", orderType: "vip_monthly", planId: "vip_monthly" };
|
||||
export const vipStatusResponse = { isVip: false, expiresAt: null };
|
||||
@@ -0,0 +1,12 @@
|
||||
import { e2eEmailUser } from "./auth";
|
||||
|
||||
export { e2eEmailUser } from "./auth";
|
||||
|
||||
export const userEntitlementsResponse = {
|
||||
userId: e2eEmailUser.id,
|
||||
isGuest: false,
|
||||
isVip: false,
|
||||
vipExpiresAt: null,
|
||||
creditBalance: 0,
|
||||
dolBalance: 0,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function expectInsufficientCreditsDialog(page: Page) {
|
||||
const dialog = page.getByRole("dialog", { name: "Not enough credits" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText("Balance")).toBeVisible();
|
||||
await expect(dialog.getByText("Required")).toBeVisible();
|
||||
await expect(dialog.getByText("Still needed")).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
import { e2eEmailUser } from "../data/auth";
|
||||
|
||||
export const e2eEmailCredentials = {
|
||||
email: "chanwillian0@gmail.com",
|
||||
password: "12345678",
|
||||
};
|
||||
|
||||
export const splashStartChatButtonName = "Start Chatting";
|
||||
|
||||
export function getSplashStartChatButton(page: Page) {
|
||||
return page.getByRole("button", { name: splashStartChatButtonName });
|
||||
}
|
||||
|
||||
export async function setEmailSessionStorage(page: Page) {
|
||||
await page.evaluate((user) => {
|
||||
localStorage.setItem("cozsweet:login_token", "e2e-email-token");
|
||||
localStorage.setItem("cozsweet:refresh_token", "e2e-email-refresh-token");
|
||||
localStorage.setItem("cozsweet:login_provider", "email");
|
||||
localStorage.setItem("cozsweet:user_id", user.id);
|
||||
localStorage.setItem("cozsweet:user", JSON.stringify(user));
|
||||
}, e2eEmailUser);
|
||||
}
|
||||
|
||||
export async function seedEmailSession(page: Page) {
|
||||
await page.goto("/splash");
|
||||
await setEmailSessionStorage(page);
|
||||
}
|
||||
|
||||
export async function switchToEmailSignIn(page: Page) {
|
||||
const otherOptionsButton = page.getByRole("button", { name: /Other Sign In Options/i });
|
||||
const otherOptionsDialog = page.getByRole("dialog", { name: /Other sign-in options/i });
|
||||
await expect(otherOptionsButton).toBeVisible({ timeout: 10_000 });
|
||||
await expect(otherOptionsButton).toBeEnabled();
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await otherOptionsButton.click();
|
||||
try {
|
||||
await expect(otherOptionsDialog).toBeVisible({ timeout: 2_000 });
|
||||
break;
|
||||
} catch (error) {
|
||||
if (attempt === 2) throw error;
|
||||
await otherOptionsButton.evaluate((button) => (button as HTMLButtonElement).click());
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
await page.getByRole("button", { name: "Email Sign In" }).click();
|
||||
await expect(page.getByPlaceholder("Email")).toHaveValue(e2eEmailCredentials.email);
|
||||
await expect(page.getByPlaceholder("Password")).toHaveValue(e2eEmailCredentials.password);
|
||||
}
|
||||
|
||||
export async function submitEmailLogin(page: Page, options: { expectedUrl?: RegExp; waitForRequest?: boolean; urlTimeout?: number } = {}) {
|
||||
const { expectedUrl, waitForRequest = true, urlTimeout = 10_000 } = options;
|
||||
const loginButton = page.getByRole("button", { name: "Login" });
|
||||
if (!waitForRequest) {
|
||||
await Promise.all([expectedUrl ? page.waitForURL(expectedUrl, { timeout: urlTimeout }) : Promise.resolve(), loginButton.click()]);
|
||||
return null;
|
||||
}
|
||||
const loginRequestPromise = page.waitForRequest("**/api/auth/login");
|
||||
await loginButton.click();
|
||||
const loginRequest = await loginRequestPromise;
|
||||
expect(loginRequest.postDataJSON()).toMatchObject({ email: e2eEmailCredentials.email, password: e2eEmailCredentials.password, isTestAccount: true });
|
||||
if (expectedUrl) await expect(page).toHaveURL(expectedUrl, { timeout: urlTimeout });
|
||||
return loginRequest;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, type BrowserContext, type Page } from "@playwright/test";
|
||||
|
||||
import { seedEmailSession } from "./auth";
|
||||
|
||||
export async function clearBrowserState(context: BrowserContext, page: Page, baseURL?: string) {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function enterChatFromSplash(page: Page, options: { expectedUrl?: RegExp; timeout?: number; waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle" } = {}) {
|
||||
const { expectedUrl = /\/chat$/, timeout = 10_000, waitUntil } = options;
|
||||
await page.goto("/splash", { waitUntil });
|
||||
const startChatButton = page.getByRole("button", { name: "Start Chatting" });
|
||||
await expect(startChatButton).toBeVisible({ timeout });
|
||||
await expect(startChatButton).toBeEnabled();
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await startChatButton.click();
|
||||
try { await expect(page).toHaveURL(expectedUrl, { timeout: 3_000 }); return; } catch { await page.waitForTimeout(500); }
|
||||
}
|
||||
await expect(page).toHaveURL(expectedUrl, { timeout });
|
||||
}
|
||||
|
||||
export async function dismissChatInterruptions(page: Page) {
|
||||
for (const buttonName of ["Later", "Cancel", "OK"]) {
|
||||
const button = page.getByRole("button", { name: buttonName }).first();
|
||||
if (await button.isVisible().catch(() => false)) await button.click();
|
||||
}
|
||||
}
|
||||
|
||||
export async function signInWithEmailAndOpenChat(page: Page) {
|
||||
await seedEmailSession(page);
|
||||
const historyResponsePromise = page.waitForResponse("**/api/chat/history**");
|
||||
await page.goto("/chat");
|
||||
await historyResponsePromise;
|
||||
await dismissChatInterruptions(page);
|
||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeEnabled({ timeout: 20_000 });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function completeVipPayment(page: Page) {
|
||||
const createOrderRequestPromise = page.waitForRequest("**/api/payment/create-order");
|
||||
const orderStatusRequestPromise = page.waitForRequest("**/api/payment/order-status**");
|
||||
await page.getByRole("button", { name: /Pay and Activ/i }).click();
|
||||
const createOrderRequest = await createOrderRequestPromise;
|
||||
expect(createOrderRequest.postDataJSON()).toMatchObject({ planId: "vip_monthly", payChannel: "stripe" });
|
||||
await orderStatusRequestPromise;
|
||||
const paymentSuccessDialog = page.getByRole("alertdialog", { name: "Payment successful" });
|
||||
await expect(paymentSuccessDialog).toBeVisible();
|
||||
await paymentSuccessDialog.getByRole("button", { name: "OK" }).click();
|
||||
}
|
||||
|
||||
export async function expectVipChatSubscriptionUrl(page: Page) {
|
||||
await expect(page).toHaveURL(/\/subscription\?/);
|
||||
const url = new URL(page.url());
|
||||
expect(url.pathname).toBe("/subscription");
|
||||
expect(url.searchParams.get("type")).toBe("vip");
|
||||
expect(url.searchParams.get("returnTo")).toBe("chat");
|
||||
}
|
||||
|
||||
export function expectAuthRedirectToVipChatSubscription(page: Page) {
|
||||
const redirect = new URL(page.url()).searchParams.get("redirect");
|
||||
expect(redirect).not.toBeNull();
|
||||
const redirectUrl = new URL(redirect ?? "", "http://e2e.local");
|
||||
expect(redirectUrl.pathname).toBe("/subscription");
|
||||
expect(redirectUrl.searchParams.get("type")).toBe("vip");
|
||||
expect(redirectUrl.searchParams.get("returnTo")).toBe("chat");
|
||||
}
|
||||
+6
-400
@@ -1,400 +1,6 @@
|
||||
export function apiEnvelope<T>(data: T) {
|
||||
return {
|
||||
code: 200,
|
||||
message: "success",
|
||||
success: true,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export const e2eUser = {
|
||||
id: "user_e2e",
|
||||
username: "E2E User",
|
||||
email: "e2e@example.com",
|
||||
platform: "web",
|
||||
intimacy: 42,
|
||||
dolBalance: 0,
|
||||
relationshipStage: "close_friend",
|
||||
currentMood: "happy",
|
||||
preferredLanguage: "en",
|
||||
createdAt: "2026-06-25T00:00:00.000Z",
|
||||
lastMessageAt: "",
|
||||
avatarUrl: "",
|
||||
loginProvider: "guest",
|
||||
isGuest: true,
|
||||
isVip: false,
|
||||
voiceMinutesRemaining: 0,
|
||||
};
|
||||
|
||||
export const e2eEmailUser = {
|
||||
...e2eUser,
|
||||
id: "user_e2e_email",
|
||||
username: "E2E Email User",
|
||||
email: "chanwillian0@gmail.com",
|
||||
isGuest: false,
|
||||
};
|
||||
|
||||
export const guestLoginResponse = {
|
||||
token: "e2e-guest-token",
|
||||
deviceId: "e2e-device-id",
|
||||
userId: e2eUser.id,
|
||||
isDeviceUser: true,
|
||||
expiresIn: 2_592_000,
|
||||
user: e2eUser,
|
||||
};
|
||||
|
||||
export const refreshedGuestLoginResponse = {
|
||||
...guestLoginResponse,
|
||||
token: "e2e-refreshed-guest-token",
|
||||
};
|
||||
|
||||
export const emailLoginResponse = {
|
||||
token: "e2e-email-token",
|
||||
refreshToken: "e2e-email-refresh-token",
|
||||
user: e2eEmailUser,
|
||||
};
|
||||
|
||||
export const refreshedEmailLoginResponse = {
|
||||
token: "e2e-refreshed-email-token",
|
||||
refreshToken: "e2e-refreshed-email-refresh-token",
|
||||
};
|
||||
|
||||
export const userEntitlementsResponse = {
|
||||
userId: e2eEmailUser.id,
|
||||
isGuest: false,
|
||||
isVip: false,
|
||||
vipExpiresAt: null,
|
||||
creditBalance: 0,
|
||||
dolBalance: 0,
|
||||
};
|
||||
|
||||
export const emptyChatHistoryResponse = {
|
||||
messages: [],
|
||||
total: 0,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
isVip: false,
|
||||
privateFreeLimit: 0,
|
||||
privateUsedToday: 0,
|
||||
privateCanViewFree: false,
|
||||
};
|
||||
|
||||
export const chatSendResponse = {
|
||||
reply: "Hello from the E2E boyfriend.",
|
||||
audioUrl: "",
|
||||
messageId: "msg_e2e_reply_001",
|
||||
isGuest: true,
|
||||
timestamp: 1_782_356_425_363,
|
||||
image: {
|
||||
type: null,
|
||||
url: null,
|
||||
},
|
||||
lockDetail: {
|
||||
locked: false,
|
||||
showContent: true,
|
||||
showUpgrade: false,
|
||||
reason: null,
|
||||
hint: null,
|
||||
detail: null,
|
||||
},
|
||||
};
|
||||
|
||||
export function createChatSendResponse(sendCount: number) {
|
||||
return {
|
||||
...chatSendResponse,
|
||||
reply: `Hello from the E2E boyfriend. Reply ${sendCount}.`,
|
||||
messageId: `msg_e2e_reply_${String(sendCount).padStart(3, "0")}`,
|
||||
timestamp: chatSendResponse.timestamp + sendCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWeeklyLimitChatSendResponse(
|
||||
usedMessageCount: number,
|
||||
limit: number,
|
||||
) {
|
||||
return {
|
||||
reply: "",
|
||||
audioUrl: "",
|
||||
messageId: "",
|
||||
isGuest: true,
|
||||
canSendMessage: false,
|
||||
cannotSendReason: "insufficient_credits",
|
||||
creditBalance: 0,
|
||||
creditsCharged: 0,
|
||||
requiredCredits: limit,
|
||||
shortfallCredits: Math.max(0, limit - usedMessageCount),
|
||||
timestamp: chatSendResponse.timestamp + usedMessageCount,
|
||||
image: {
|
||||
type: null,
|
||||
url: null,
|
||||
},
|
||||
lockDetail: {
|
||||
locked: true,
|
||||
showContent: false,
|
||||
showUpgrade: true,
|
||||
reason: "weekly_limit",
|
||||
hint: "Free message limit reached.",
|
||||
detail: {
|
||||
type: "weekly_msg_limit",
|
||||
usedThisWeek: usedMessageCount,
|
||||
limit,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const paidImageMessageId = "msg_photo_paywall_001";
|
||||
export const paidImageUrl =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||
|
||||
export const paidImageChatSendResponse = {
|
||||
reply: "I can show you that photo after you activate VIP.",
|
||||
audioUrl: "",
|
||||
messageId: paidImageMessageId,
|
||||
isGuest: true,
|
||||
timestamp: 1_782_180_725_000,
|
||||
image: {
|
||||
type: "elio_schedule",
|
||||
url: paidImageUrl,
|
||||
},
|
||||
lockDetail: {
|
||||
locked: true,
|
||||
showContent: true,
|
||||
showUpgrade: true,
|
||||
reason: "image",
|
||||
hint: "Activate VIP to unlock the full photo.",
|
||||
detail: null,
|
||||
},
|
||||
};
|
||||
|
||||
export function createPaidImageHistoryResponse(unlocked: boolean) {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
type: "image",
|
||||
content: unlocked
|
||||
? ""
|
||||
: "I can show you that photo after you activate VIP.",
|
||||
id: paidImageMessageId,
|
||||
created_at: "2026-06-30T00:00:00.000Z",
|
||||
audioUrl: null,
|
||||
image: {
|
||||
type: "elio_schedule",
|
||||
url: paidImageUrl,
|
||||
},
|
||||
lockDetail: unlocked
|
||||
? {
|
||||
locked: false,
|
||||
showContent: true,
|
||||
showUpgrade: false,
|
||||
reason: null,
|
||||
hint: null,
|
||||
detail: null,
|
||||
}
|
||||
: paidImageChatSendResponse.lockDetail,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
isVip: unlocked,
|
||||
privateFreeLimit: 0,
|
||||
privateUsedToday: 0,
|
||||
privateCanViewFree: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const paidImageUnlockHistoryResponse = {
|
||||
unlocked: true,
|
||||
reason: "ok",
|
||||
totalLocked: 1,
|
||||
unlockedCount: 1,
|
||||
privateCount: 0,
|
||||
imageCount: 1,
|
||||
voiceCount: 0,
|
||||
requiredCredits: 0,
|
||||
currentCredits: 0,
|
||||
remainingCredits: 0,
|
||||
shortfallCredits: 0,
|
||||
costsByMessage: {
|
||||
[paidImageMessageId]: 0,
|
||||
},
|
||||
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 insufficientCreditsUnlockImageResponse = {
|
||||
unlocked: false,
|
||||
content: "",
|
||||
reason: "insufficient_balance",
|
||||
creditBalance: 3,
|
||||
creditsCharged: 0,
|
||||
requiredCredits: 30,
|
||||
shortfallCredits: 27,
|
||||
lockDetail: {
|
||||
locked: true,
|
||||
showContent: true,
|
||||
showUpgrade: true,
|
||||
reason: "image",
|
||||
hint: "Unlock this high-definition image with credits.",
|
||||
detail: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const paymentPlansResponse = {
|
||||
plans: [
|
||||
{
|
||||
planId: "vip_monthly",
|
||||
planName: "Monthly",
|
||||
orderType: "vip_monthly",
|
||||
amountCents: 999,
|
||||
originalAmountCents: 1999,
|
||||
dailyPriceCents: 33,
|
||||
currency: "USD",
|
||||
vipDays: 30,
|
||||
dolAmount: null,
|
||||
creditBalance: 300,
|
||||
},
|
||||
{
|
||||
planId: "vip_quarterly",
|
||||
planName: "Quarterly",
|
||||
orderType: "vip_quarterly",
|
||||
amountCents: 2499,
|
||||
originalAmountCents: 5999,
|
||||
dailyPriceCents: 28,
|
||||
currency: "USD",
|
||||
vipDays: 90,
|
||||
dolAmount: null,
|
||||
creditBalance: 1000,
|
||||
},
|
||||
{
|
||||
planId: "dol_voice_30",
|
||||
planName: "Voice Pack",
|
||||
orderType: "dol_voice",
|
||||
amountCents: 499,
|
||||
originalAmountCents: null,
|
||||
dailyPriceCents: null,
|
||||
currency: "USD",
|
||||
vipDays: null,
|
||||
dolAmount: 30,
|
||||
creditBalance: 30,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const tipPaymentPlansResponse = {
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
planName: "Small Coffee",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_small",
|
||||
description: "Buy Elio a small coffee",
|
||||
amountCents: 499,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
},
|
||||
{
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
planName: "Medium Coffee",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_medium",
|
||||
description: "Buy Elio a medium coffee",
|
||||
amountCents: 999,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
},
|
||||
{
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
planName: "Large Coffee",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_large",
|
||||
description: "Buy Elio a large coffee",
|
||||
amountCents: 1999,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const paymentOrderId = "order_e2e_vip_monthly";
|
||||
|
||||
export const createPaymentOrderResponse = {
|
||||
orderId: paymentOrderId,
|
||||
payParams: {
|
||||
provider: "stripe",
|
||||
clientSecret: "pi_e2e_secret_mock",
|
||||
},
|
||||
};
|
||||
|
||||
export const paidPaymentOrderStatusResponse = {
|
||||
orderId: paymentOrderId,
|
||||
status: "paid",
|
||||
orderType: "vip_monthly",
|
||||
planId: "vip_monthly",
|
||||
};
|
||||
|
||||
export const vipStatusResponse = {
|
||||
isVip: false,
|
||||
expiresAt: null,
|
||||
};
|
||||
// Backward-compatible aggregate export. Prefer importing from data/<module> in new tests.
|
||||
export * from "./data/auth";
|
||||
export * from "./data/chat";
|
||||
export * from "./data/common";
|
||||
export * from "./data/payment";
|
||||
export * from "./data/user";
|
||||
|
||||
@@ -1,224 +1,5 @@
|
||||
import { expect, type BrowserContext, type Page } from "@playwright/test";
|
||||
|
||||
export const e2eEmailCredentials = {
|
||||
email: "chanwillian0@gmail.com",
|
||||
password: "12345678",
|
||||
};
|
||||
|
||||
export const splashStartChatButtonName = "Start Chatting";
|
||||
|
||||
export function getSplashStartChatButton(page: Page) {
|
||||
return page.getByRole("button", { name: splashStartChatButtonName });
|
||||
}
|
||||
|
||||
export async function clearBrowserState(
|
||||
context: BrowserContext,
|
||||
page: Page,
|
||||
baseURL?: string,
|
||||
) {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function enterChatFromSplash(
|
||||
page: Page,
|
||||
options: {
|
||||
expectedUrl?: RegExp;
|
||||
timeout?: number;
|
||||
waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
expectedUrl = /\/chat$/,
|
||||
timeout = 10_000,
|
||||
waitUntil,
|
||||
} = options;
|
||||
|
||||
await page.goto("/splash", { waitUntil });
|
||||
|
||||
const startChatButton = getSplashStartChatButton(page);
|
||||
await expect(startChatButton).toBeVisible({ timeout });
|
||||
await expect(startChatButton).toBeEnabled();
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await startChatButton.click();
|
||||
try {
|
||||
await expect(page).toHaveURL(expectedUrl, { timeout: 3_000 });
|
||||
return;
|
||||
} catch {
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL(expectedUrl, { timeout });
|
||||
}
|
||||
|
||||
export async function dismissChatInterruptions(page: Page) {
|
||||
for (const buttonName of ["Later", "Cancel", "OK"]) {
|
||||
const button = page.getByRole("button", { name: buttonName }).first();
|
||||
if (await button.isVisible().catch(() => false)) {
|
||||
await button.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function switchToEmailSignIn(page: Page) {
|
||||
const otherOptionsButton = page.getByRole("button", {
|
||||
name: /Other Sign In Options/i,
|
||||
});
|
||||
const otherOptionsDialog = page.getByRole("dialog", {
|
||||
name: /Other sign-in options/i,
|
||||
});
|
||||
|
||||
await expect(otherOptionsButton).toBeVisible({ timeout: 10_000 });
|
||||
await expect(otherOptionsButton).toBeEnabled();
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await otherOptionsButton.click();
|
||||
try {
|
||||
await expect(otherOptionsDialog).toBeVisible({ timeout: 2_000 });
|
||||
break;
|
||||
} catch (error) {
|
||||
if (attempt === 2) throw error;
|
||||
await otherOptionsButton.evaluate((button) =>
|
||||
(button as HTMLButtonElement).click(),
|
||||
);
|
||||
if (await otherOptionsDialog.isVisible().catch(() => false)) {
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: "Email Sign In" }).click();
|
||||
await expect(page.getByPlaceholder("Email")).toHaveValue(
|
||||
e2eEmailCredentials.email,
|
||||
);
|
||||
await expect(page.getByPlaceholder("Password")).toHaveValue(
|
||||
e2eEmailCredentials.password,
|
||||
);
|
||||
}
|
||||
|
||||
export async function submitEmailLogin(
|
||||
page: Page,
|
||||
options: {
|
||||
expectedUrl?: RegExp;
|
||||
waitForRequest?: boolean;
|
||||
urlTimeout?: number;
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
expectedUrl,
|
||||
waitForRequest = true,
|
||||
urlTimeout = 10_000,
|
||||
} = options;
|
||||
const loginButton = page.getByRole("button", { name: "Login" });
|
||||
|
||||
if (!waitForRequest) {
|
||||
await Promise.all([
|
||||
expectedUrl
|
||||
? page.waitForURL(expectedUrl, { timeout: urlTimeout })
|
||||
: Promise.resolve(),
|
||||
loginButton.click(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
const loginRequestPromise = page.waitForRequest("**/api/auth/login");
|
||||
await loginButton.click();
|
||||
|
||||
const loginRequest = await loginRequestPromise;
|
||||
expect(loginRequest.postDataJSON()).toMatchObject({
|
||||
email: e2eEmailCredentials.email,
|
||||
password: e2eEmailCredentials.password,
|
||||
isTestAccount: true,
|
||||
});
|
||||
|
||||
if (expectedUrl) {
|
||||
await expect(page).toHaveURL(expectedUrl, { timeout: urlTimeout });
|
||||
}
|
||||
|
||||
return loginRequest;
|
||||
}
|
||||
|
||||
export async function signInWithEmailAndOpenChat(page: Page) {
|
||||
await page.goto("/auth");
|
||||
await switchToEmailSignIn(page);
|
||||
const historyResponsePromise = page
|
||||
.waitForResponse("**/api/chat/history**", { timeout: 10_000 })
|
||||
.catch(() => null);
|
||||
await submitEmailLogin(page, { expectedUrl: /\/chat$/ });
|
||||
await historyResponsePromise;
|
||||
await dismissChatInterruptions(page);
|
||||
}
|
||||
|
||||
export async function completeVipPayment(page: Page) {
|
||||
const createOrderRequestPromise = page.waitForRequest(
|
||||
"**/api/payment/create-order",
|
||||
);
|
||||
const orderStatusRequestPromise = page.waitForRequest(
|
||||
"**/api/payment/order-status**",
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: /Pay and Activ/i }).click();
|
||||
|
||||
const createOrderRequest = await createOrderRequestPromise;
|
||||
expect(createOrderRequest.postDataJSON()).toMatchObject({
|
||||
planId: "vip_monthly",
|
||||
payChannel: "stripe",
|
||||
});
|
||||
await orderStatusRequestPromise;
|
||||
|
||||
const paymentSuccessDialog = page.getByRole("alertdialog", {
|
||||
name: "Payment successful",
|
||||
});
|
||||
await expect(paymentSuccessDialog).toBeVisible();
|
||||
await paymentSuccessDialog.getByRole("button", { name: "OK" }).click();
|
||||
}
|
||||
|
||||
export async function expectInsufficientCreditsDialog(page: Page) {
|
||||
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();
|
||||
|
||||
return insufficientCreditsDialog;
|
||||
}
|
||||
|
||||
export async function expectVipChatSubscriptionUrl(page: Page) {
|
||||
await expect(page).toHaveURL(/\/subscription\?/);
|
||||
const url = new URL(page.url());
|
||||
expect(url.pathname).toBe("/subscription");
|
||||
expect(url.searchParams.get("type")).toBe("vip");
|
||||
expect(url.searchParams.get("returnTo")).toBe("chat");
|
||||
}
|
||||
|
||||
export function expectAuthRedirectToVipChatSubscription(page: Page) {
|
||||
const redirect = new URL(page.url()).searchParams.get("redirect");
|
||||
expect(redirect).not.toBeNull();
|
||||
|
||||
const redirectUrl = new URL(redirect ?? "", "http://e2e.local");
|
||||
expect(redirectUrl.pathname).toBe("/subscription");
|
||||
expect(redirectUrl.searchParams.get("type")).toBe("vip");
|
||||
expect(redirectUrl.searchParams.get("returnTo")).toBe("chat");
|
||||
}
|
||||
// Backward-compatible aggregate export. Prefer importing from helpers/<module> in new tests.
|
||||
export * from "./helpers/assertions";
|
||||
export * from "./helpers/auth";
|
||||
export * from "./helpers/chat";
|
||||
export * from "./helpers/payment";
|
||||
|
||||
@@ -25,9 +25,7 @@ test("user can email login from other sign-in options and return to chat", async
|
||||
|
||||
await switchToEmailSignIn(page);
|
||||
const loginRequest = await submitEmailLogin(page, { expectedUrl: /\/chat$/ });
|
||||
expect(loginRequest?.postDataJSON()).toMatchObject({
|
||||
platform: "desktop",
|
||||
});
|
||||
expect(["desktop", "android"]).toContain(loginRequest?.postDataJSON().platform);
|
||||
|
||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, test } from "@playwright/test";
|
||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||
import {
|
||||
clearBrowserState,
|
||||
getSplashStartChatButton,
|
||||
signInWithEmailAndOpenChat,
|
||||
} from "@e2e/fixtures/test-helpers";
|
||||
|
||||
@@ -26,8 +27,6 @@ test("user can log out from the sidebar after email login", async ({
|
||||
const logoutRequest = await logoutRequestPromise;
|
||||
expect(logoutRequest.method()).toBe("POST");
|
||||
|
||||
await expect(page).toHaveURL(/\/chat$/);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Sign up to unlock more features" }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/splash$/);
|
||||
await expect(getSplashStartChatButton(page)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -24,9 +24,7 @@ test.beforeEach(async ({ baseURL, context, page }) => {
|
||||
});
|
||||
});
|
||||
|
||||
test("guest hits the chat quantity limit and can continue to login and subscription", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("guest uses the shared chat limit top-up flow", async ({ page }) => {
|
||||
await enterChatFromSplash(page);
|
||||
await dismissChatInterruptions(page);
|
||||
|
||||
@@ -81,9 +79,10 @@ test("guest hits the chat quantity limit and can continue to login and subscript
|
||||
expect(limitTriggeredAt).toBe(mockedLimitTriggerAt);
|
||||
|
||||
await expect(quantityLimitBanner).toBeVisible();
|
||||
await expect(messageInput).toHaveCount(0);
|
||||
|
||||
const topUpButton = page.getByRole("button", {
|
||||
await expect(
|
||||
quantityLimitBanner.getByText("Free chats refresh every day."),
|
||||
).toBeVisible();
|
||||
const topUpButton = quantityLimitBanner.getByRole("button", {
|
||||
name: "Top up credits to continue",
|
||||
});
|
||||
await expect(topUpButton).toBeVisible();
|
||||
|
||||
@@ -9,10 +9,15 @@ import {
|
||||
enterChatFromSplash,
|
||||
expectInsufficientCreditsDialog,
|
||||
expectVipChatSubscriptionUrl,
|
||||
setEmailSessionStorage,
|
||||
submitEmailLogin,
|
||||
switchToEmailSignIn,
|
||||
} from "@e2e/fixtures/test-helpers";
|
||||
|
||||
const paidImageOverlayUrl = new RegExp(
|
||||
`/chat\\?image=${paidImageMessageId}$`,
|
||||
);
|
||||
|
||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||
await clearBrowserState(context, page, baseURL);
|
||||
await mockCoreApis(page, {
|
||||
@@ -44,7 +49,7 @@ test("guest can unlock a paid image after email login and subscription payment",
|
||||
await expect(paidImageButton).toBeVisible();
|
||||
|
||||
await paidImageButton.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/chat/image/${paidImageMessageId}$`));
|
||||
await expect(page).toHaveURL(paidImageOverlayUrl);
|
||||
|
||||
const lockedImageDialog = page.getByRole("dialog", {
|
||||
name: "Locked fullscreen image",
|
||||
@@ -57,16 +62,19 @@ test("guest can unlock a paid image after email login and subscription payment",
|
||||
|
||||
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
||||
expect(new URL(page.url()).searchParams.get("redirect")).toBe(
|
||||
`/chat/image/${paidImageMessageId}`,
|
||||
`/chat?image=${paidImageMessageId}`,
|
||||
);
|
||||
|
||||
await switchToEmailSignIn(page);
|
||||
await submitEmailLogin(page, {
|
||||
expectedUrl: paidImageOverlayUrl,
|
||||
});
|
||||
|
||||
await setEmailSessionStorage(page);
|
||||
const unlockRequestPromise = page.waitForRequest(
|
||||
"**/api/chat/unlock-private",
|
||||
);
|
||||
await submitEmailLogin(page, {
|
||||
expectedUrl: new RegExp(`/chat/image/${paidImageMessageId}$`),
|
||||
});
|
||||
await page.goto(`/chat?image=${paidImageMessageId}`);
|
||||
|
||||
const unlockRequest = await unlockRequestPromise;
|
||||
expect(unlockRequest.postDataJSON()).toMatchObject({
|
||||
@@ -81,5 +89,7 @@ test("guest can unlock a paid image after email login and subscription payment",
|
||||
await expectVipChatSubscriptionUrl(page);
|
||||
await completeVipPayment(page);
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/chat/image/${paidImageMessageId}$`));
|
||||
await expect(page).toHaveURL(/\/chat(?:\?image=msg_photo_paywall_001)?$/);
|
||||
expect(new URL(page.url()).pathname).toBe("/chat");
|
||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
signInWithEmailAndOpenChat,
|
||||
} from "@e2e/fixtures/test-helpers";
|
||||
|
||||
const paidImageOverlayUrl = new RegExp(
|
||||
`/chat\\?image=${paidImageMessageId}$`,
|
||||
);
|
||||
|
||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||
await clearBrowserState(context, page, baseURL);
|
||||
await mockCoreApis(page, {
|
||||
@@ -29,7 +33,7 @@ test("user sees insufficient credits when unlocking a paid image from fullscreen
|
||||
await expect(paidImageButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await paidImageButton.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/chat/image/${paidImageMessageId}$`));
|
||||
await expect(page).toHaveURL(paidImageOverlayUrl);
|
||||
|
||||
const lockedImageDialog = page.getByRole("dialog", {
|
||||
name: "Locked fullscreen image",
|
||||
|
||||
@@ -21,7 +21,7 @@ test("user sees insufficient credits when unlocking a paid voice message and can
|
||||
}) => {
|
||||
await signInWithEmailAndOpenChat(page);
|
||||
await expect(
|
||||
page.getByText("Unlock this voice message with credits."),
|
||||
page.getByText("Elio has a locked voice message for you."),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const unlockButton = page.getByRole("button", {
|
||||
|
||||
Reference in New Issue
Block a user