chore(release): promote pre changes to production main
Docker Image / Build and Push Docker Image (push) Successful in 2m20s

This commit is contained in:
Codex
2026-07-28 10:57:09 +08:00
96 changed files with 2053 additions and 453 deletions
@@ -1,49 +1,74 @@
import { expect, test } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import {
clearBrowserState,
defaultCharacterChatUrl,
} from "@e2e/fixtures/test-helpers";
import { clearBrowserState } from "@e2e/fixtures/test-helpers";
const psid = "e2e-facebook-psid";
const handoffToken = "e2e-handoff-token-that-is-at-least-32-characters";
test.beforeEach(async ({ baseURL, context, page }) => {
await clearBrowserState(context, page, baseURL);
await mockCoreApis(page, { psidLoginFlow: true });
await mockCoreApis(page, { topUpHandoffFlow: true });
});
test("user enters from an external PSID link and becomes a guest", async ({
test("user consumes a top-up handoff and opens credit packages as a formal Messenger user", async ({
page,
}) => {
const psidLoginRequestPromise = page.waitForRequest(
"**/api/auth/login/facebook/psid",
const handoffRequestPromise = page.waitForRequest(
"**/api/auth/handoff/topup/consume",
);
await page.goto(`/external-entry?target=chat&psid=${psid}`);
await page.goto(
`/external-entry?target=topup&handoffToken=${encodeURIComponent(handoffToken)}`,
);
const psidLoginRequest = await psidLoginRequestPromise;
expect(psidLoginRequest.method()).toBe("POST");
expect(psidLoginRequest.postDataJSON()).toMatchObject({
psid,
bindToGuest: true,
});
expect(psidLoginRequest.postDataJSON().deviceId).toEqual(expect.any(String));
const handoffRequest = await handoffRequestPromise;
expect(handoffRequest.method()).toBe("POST");
expect(handoffRequest.postDataJSON()).toEqual({ handoffToken });
await expect(page).toHaveURL(defaultCharacterChatUrl);
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
await expect(page).toHaveURL("/subscription?type=topup");
await expect(
page.getByRole("button", { name: /Pay and Top Up/i }),
).toBeVisible();
await expect
.poll(() =>
page.evaluate(() => ({
loginProvider: localStorage.getItem("cozsweet:login_provider"),
guestToken: localStorage.getItem("cozsweet:guest_token"),
psid: localStorage.getItem("cozsweet:psid"),
loginToken: localStorage.getItem("cozsweet:login_token"),
refreshToken: localStorage.getItem("cozsweet:refresh_token"),
})),
)
.toMatchObject({
loginProvider: "guest",
guestToken: "e2e-psid-guest-token",
psid,
loginProvider: "facebookMessenger",
loginToken: "e2e-messenger-token",
refreshToken: "e2e-messenger-refresh-token",
});
});
test("invalid top-up handoff does not log in and is removed from the URL", async ({
page,
}) => {
await page.route("**/api/auth/handoff/topup/consume", async (route) => {
await route.fulfill({
status: 410,
json: {
detail: {
code: "HANDOFF_EXPIRED",
message: "充值链接已过期",
},
},
});
});
await page.goto(
`/external-entry?target=topup&handoffToken=${encodeURIComponent(handoffToken)}`,
);
await expect(page).toHaveURL("/external-entry?target=topup");
await expect(
page.getByText(/invalid or has expired/i),
).toBeVisible();
await expect
.poll(() => page.evaluate(() => localStorage.getItem("cozsweet:login_provider")))
.not.toBe("facebookMessenger");
});
@@ -4,7 +4,6 @@ import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import {
clearBrowserState,
enterChatFromSplash,
expectAuthRedirectToVipChatSubscription,
} from "@e2e/fixtures/test-helpers";
test.beforeEach(async ({ baseURL, context, page }) => {
@@ -44,12 +43,7 @@ test("asks for consent before opening a user-bound discount plan", async ({
await expect(offer).toContainText("Want me to ask for my best private offer?");
await offer.getByRole("button", { name: "Yes, ask for me" }).click();
await expect(page).toHaveURL(/\/auth\?redirect=/);
expectAuthRedirectToVipChatSubscription(page);
const redirect = new URL(page.url()).searchParams.get("redirect");
const subscriptionUrl = new URL(redirect ?? "", "http://e2e.local");
expect(subscriptionUrl.searchParams.get("planId")).toBe("vip_annual");
expect(subscriptionUrl.searchParams.get("commercialOfferId")).toBe(
"13ec8a10-58d7-4d24-b66b-8db5699a1aa8",
await expect(page).toHaveURL(
/\/subscription\?type=vip.*planId=vip_annual.*commercialOfferId=13ec8a10/,
);
});
@@ -182,7 +182,7 @@ async function registerFavoriteMenuMocks(page: Page): Promise<void> {
function collectBrowserErrors(page: Page): string[] {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.stack ?? error.message));
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
@@ -193,8 +193,7 @@ function isFeatureRuntimeError(message: string): boolean {
const isKnownLocalPreviewDependencyError =
message.includes("[ApiLoggingInterceptor]") ||
message.includes("[next-auth][error][CLIENT_FETCH_ERROR]") ||
message.includes("[Result] Result.wrap caught exception") ||
message.includes("Cannot read properties of undefined (reading 'waiting')");
message.includes("[Result] Result.wrap caught exception");
return !isKnownLocalPreviewDependencyError;
}
@@ -0,0 +1,333 @@
import { expect, test, type Page } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { apiEnvelope } from "@e2e/fixtures/data/common";
import { e2eEmailUser } from "@e2e/fixtures/data/user";
import {
clearBrowserState,
seedEmailSession,
} from "@e2e/fixtures/test-helpers";
const idrPlans = {
plans: [
{
planId: "vip_monthly",
planName: "Monthly",
orderType: "vip_monthly",
amountCents: 19_590_000,
originalAmountCents: 19_590_000,
dailyPriceCents: 653_000,
currency: "IDR",
vipDays: 30,
dolAmount: null,
creditBalance: 3_000,
},
{
planId: "dol_1000",
planName: "1,000 Credits",
orderType: "dol",
amountCents: 8_890_000,
originalAmountCents: null,
dailyPriceCents: null,
currency: "IDR",
vipDays: null,
dolAmount: 1_000,
creditBalance: 1_000,
},
],
};
const idrGiftCatalog = {
characterId: "elio",
categories: [
{
category: "coffee",
name: "Coffee",
productCount: 1,
imageUrl: null,
},
],
plans: [
{
planId: "tip_coffee_usd_4_99",
planName: "Velvet Espresso",
orderType: "tip",
tipType: "coffee_small",
category: "coffee",
characterId: "elio",
description: "Buy Elio a Velvet Espresso",
imageUrl: null,
amountCents: 8_929_900,
currency: "IDR",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
promotionType: null,
},
],
};
async function registerIndonesiaPaymentMocks(page: Page) {
const orderStatuses = new Map<string, "pending" | "paid">();
const orderPlans = new Map<string, { planId: string; orderType: string }>();
let createOrderCount = 0;
await page.route("**/api/user/profile", async (route) => {
await route.fulfill({
json: apiEnvelope({ ...e2eEmailUser, countryCode: "ID" }),
});
});
await page.route("**/api/payment/plans**", async (route) => {
await route.fulfill({ json: apiEnvelope(idrPlans) });
});
await page.route("**/api/payment/gift-products**", async (route) => {
await route.fulfill({ json: apiEnvelope(idrGiftCatalog) });
});
await page.route("**/api/payment/create-order", async (route) => {
createOrderCount += 1;
const body = route.request().postDataJSON() as { planId: string };
const plan =
idrPlans.plans.find((item) => item.planId === body.planId) ??
idrGiftCatalog.plans.find((item) => item.planId === body.planId);
const orderId = `order_qris_${body.planId}`;
orderStatuses.set(orderId, "pending");
orderPlans.set(orderId, {
planId: body.planId,
orderType: plan?.orderType ?? "dol",
});
await route.fulfill({
json: apiEnvelope({
orderId,
payParams: {
provider: "ezpay",
countryCode: "ID",
channelCode: "ID_QRIS_DYNAMIC_QR",
channelType: "QR",
payData: "00020101021226670016COM.NOBUBANK.WWW",
firstChargeAmountCents: plan?.amountCents ?? 0,
currency: "IDR",
},
}),
});
});
await page.route("**/api/payment/order-status**", async (route) => {
const url = new URL(route.request().url());
const orderId = url.searchParams.get("order_id") ?? "";
const plan = orderPlans.get(orderId);
await route.fulfill({
json: apiEnvelope({
orderId,
status: orderStatuses.get(orderId) ?? "pending",
orderType: plan?.orderType ?? "dol",
planId: plan?.planId ?? null,
creditsAdded: 0,
}),
});
});
await page.route("**/api/payment/tip-message", async (route) => {
const body = route.request().postDataJSON() as { orderId: string };
await route.fulfill({
json: apiEnvelope({
orderId: body.orderId,
characterId: "elio",
planId: "tip_coffee_usd_4_99",
productName: "Velvet Espresso",
tipCount: 1,
poolIndex: 0,
message: "Thank you. Your gift made me smile.",
}),
});
});
return {
getCreateOrderCount() {
return createOrderCount;
},
markPaid(orderId: string) {
orderStatuses.set(orderId, "paid");
},
};
}
async function prepareIndonesiaUser(page: Page) {
await seedEmailSession(page);
await page.evaluate(() => {
const rawUser = localStorage.getItem("cozsweet:user");
const user = rawUser ? JSON.parse(rawUser) : {};
localStorage.setItem(
"cozsweet:user",
JSON.stringify({ ...user, countryCode: "ID" }),
);
});
}
async function expectQrisOrder(
page: Page,
buttonName: RegExp,
planId: string,
formattedAmount: RegExp,
) {
const requestPromise = page.waitForRequest("**/api/payment/create-order");
await page.getByRole("button", { name: buttonName }).click();
const request = await requestPromise;
expect(request.postDataJSON()).toMatchObject({
planId,
payChannel: "ezpay",
});
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(formattedAmount);
await expect(dialog).toContainText("Waiting for payment");
await expect(
dialog.getByRole("img", { name: "QRIS payment QR code" }),
).toBeVisible();
return `order_qris_${planId}`;
}
async function expectCheckoutButtonLayout(page: Page) {
const viewport = page.viewportSize();
const checkoutBox = await page
.getByRole("button", { name: "Pay and Top Up" })
.boundingBox();
const stripeBox = await page.getByRole("button", { name: "Stripe" }).boundingBox();
expect(viewport).not.toBeNull();
expect(checkoutBox).not.toBeNull();
expect(stripeBox).not.toBeNull();
if (!viewport || !checkoutBox || !stripeBox) return;
expect(checkoutBox.y + checkoutBox.height).toBeLessThanOrEqual(viewport.height);
expect(stripeBox.y + stripeBox.height).toBeLessThanOrEqual(checkoutBox.y);
}
test.beforeEach(async ({ baseURL, context, page }) => {
await clearBrowserState(context, page, baseURL);
await mockCoreApis(page);
});
test("Indonesia VIP defaults to QRIS and completes after status polling", async ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=vip");
await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
"aria-pressed",
"true",
);
const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" });
await expect(checkoutButton).toBeDisabled();
await expectCheckoutButtonLayout(page);
await page.getByRole("button", { name: /Monthly, 195900 IDR/i }).click();
const renewalDialog = page.getByRole("dialog", {
name: "Automatic Renewal Confirmation",
});
await expect(renewalDialog).toBeVisible();
expect(payment.getCreateOrderCount()).toBe(0);
await renewalDialog.getByRole("button", { name: "Confirm" }).click();
await expect(renewalDialog).toBeHidden();
await expect(checkoutButton).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(0);
const orderId = await expectQrisOrder(
page,
/Pay and Top Up/i,
"vip_monthly",
/Rp\s*195[.,]900/,
);
expect(payment.getCreateOrderCount()).toBe(1);
payment.markPaid(orderId);
const success = page.getByRole("alertdialog", { name: "Payment successful" });
await expect(success).toBeVisible({ timeout: 10_000 });
await expect(
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
).toBeHidden();
});
test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup");
await expectCheckoutButtonLayout(page);
const orderId = await expectQrisOrder(
page,
/Pay and Top Up/i,
"dol_1000",
/Rp\s*88[.,]900/,
);
payment.markPaid(orderId);
await expect(
page.getByRole("alertdialog", { name: "Payment successful" }),
).toBeVisible({ timeout: 10_000 });
});
test("payment issue submits Other to the feedback API without creating an order", async ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
let feedbackBody = "";
let idempotencyKey = "";
await page.route("**/api/feedback", async (route) => {
feedbackBody = route.request().postData() ?? "";
idempotencyKey = route.request().headers()["idempotency-key"] ?? "";
await route.fulfill({
json: apiEnvelope({ feedbackId: "feedback-payment-1", duplicate: false }),
});
});
await page.goto("/subscription?type=topup&character=elio");
await page.getByRole("button", { name: "Payment issue?" }).click();
const dialog = page.getByRole("dialog", {
name: "What problem did you encounter?",
});
await expect(dialog).toBeVisible();
await dialog.getByRole("radio", { name: "Other" }).check();
await dialog
.getByLabel("Please describe the issue")
.fill("QRIS did not open correctly.");
await dialog.getByRole("button", { name: "Submit" }).click();
await expect(page.getByRole("status")).toHaveText(
"Thanks. Your payment issue has been submitted.",
);
await expect(dialog).toBeHidden();
expect(payment.getCreateOrderCount()).toBe(0);
expect(idempotencyKey).toMatch(/^payment_feedback_[A-Za-z0-9_-]+$/);
expect(feedbackBody).toContain('name="category"');
expect(feedbackBody).toContain("payment");
expect(feedbackBody).toContain(
"Payment issue: Other\r\nDetails: QRIS did not open correctly.",
);
expect(feedbackBody).toContain('name="context"');
expect(feedbackBody).toContain('"paymentIssueReason":"other"');
expect(feedbackBody).toContain('"characterId":"elio"');
});
test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow", async ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/characters/elio/tip");
await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible();
const orderId = await expectQrisOrder(
page,
/Order and Buy/i,
"tip_coffee_usd_4_99",
/Rp\s*89[.,]299/,
);
payment.markPaid(orderId);
await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Send another gift" })).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
).toBeHidden();
});