74b7eae18b
Docker Image / Build and Push Docker Image (push) Successful in 2m14s
(cherry picked from commit ef9b79bc83)
340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
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",
|
|
recipientCharacterId: "elio",
|
|
});
|
|
|
|
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.getByText("Choose who you want to support")).toBeVisible();
|
|
await page.getByRole("button", { name: "Elio", exact: true }).click();
|
|
await expect(page.getByText("Supporting Elio")).toBeVisible();
|
|
|
|
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 page.getByRole("button", { name: "Elio", exact: true }).click();
|
|
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();
|
|
});
|