Files
cozsweet-frontend-nextjs/e2e/specs/mock/payment/indonesia-qris.spec.ts
T

429 lines
13 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;
payChannel: "ezpay" | "stripe";
};
const plan =
idrPlans.plans.find((item) => item.planId === body.planId) ??
idrGiftCatalog.plans.find((item) => item.planId === body.planId);
const orderId = `order_${body.payChannel}_${body.planId}`;
orderStatuses.set(orderId, "pending");
orderPlans.set(orderId, {
planId: body.planId,
orderType: plan?.orderType ?? "dol",
});
await route.fulfill({
json: apiEnvelope({
orderId,
payParams:
body.payChannel === "stripe"
? {
provider: "stripe",
clientSecret: "pi_e2e_secret_mock",
}
: {
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_ezpay_${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&character=elio");
await expect(page.getByText("Choose who you want to support")).toHaveCount(0);
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Elio", exact: true }),
).toHaveCount(0);
await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
"aria-pressed",
"true",
);
const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" });
await expect(checkoutButton).toBeEnabled();
await expectCheckoutButtonLayout(page);
await expect(
page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }),
).toHaveCount(0);
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&character=elio");
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
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("closing QRIS restores a resumable checkout without creating a duplicate order", async ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup&character=elio");
const orderId = await expectQrisOrder(
page,
/Pay and Top Up/i,
"dol_1000",
/Rp\s*88[.,]900/,
);
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
const resumeButton = page.getByRole("button", {
name: "Resume QRIS payment",
});
await expect(resumeButton).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1);
await resumeButton.click();
await expect(dialog).toBeVisible();
expect(payment.getCreateOrderCount()).toBe(1);
payment.markPaid(orderId);
await expect(
page.getByRole("alertdialog", { name: "Payment successful" }),
).toBeVisible({ timeout: 10_000 });
});
test("closing QRIS allows switching to Stripe and creating a Stripe order", async ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup&character=elio");
await expectQrisOrder(
page,
/Pay and Top Up/i,
"dol_1000",
/Rp\s*88[.,]900/,
);
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
const stripeButton = page.getByRole("button", {
name: "Stripe",
exact: true,
});
await expect(stripeButton).toBeEnabled();
await stripeButton.click();
await expect(stripeButton).toHaveAttribute("aria-pressed", "true");
await expect(
page.getByRole("button", { name: "Resume QRIS payment" }),
).toHaveCount(0);
const stripeRequestPromise = page.waitForRequest(
"**/api/payment/create-order",
);
await page.getByRole("button", { name: "Pay and Top Up" }).click();
const stripeRequest = await stripeRequestPromise;
expect(stripeRequest.postDataJSON()).toMatchObject({
planId: "dol_1000",
payChannel: "stripe",
recipientCharacterId: "elio",
});
expect(payment.getCreateOrderCount()).toBe(2);
});
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/,
);
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
const resumeButton = page.getByRole("button", {
name: "Resume QRIS payment",
});
await expect(resumeButton).toBeEnabled();
await resumeButton.click();
await expect(dialog).toBeVisible();
expect(payment.getCreateOrderCount()).toBe(1);
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();
});