feat(subscription): separate selection from payment and add issue feedback

(cherry picked from commit fe9d31146b)
This commit is contained in:
Codex
2026-07-27 11:35:28 +08:00
parent 30f88d3a20
commit b7221f2f70
14 changed files with 1258 additions and 73 deletions
@@ -0,0 +1,224 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Result } from "@/utils/result";
import { SubscriptionPaymentIssueDialog } from "../subscription-payment-issue-dialog";
import { SubscriptionRenewalConfirmationDialog } from "../subscription-renewal-confirmation-dialog";
const mocks = vi.hoisted(() => ({
submitFeedback: vi.fn(),
}));
vi.mock("@/lib/feedback", () => ({
submitFeedback: mocks.submitFeedback,
}));
vi.mock("@/app/feedback/feedback-context", () => ({
createFeedbackContext: () => ({
appVersion: "test",
platform: "web",
browser: "Chrome",
viewport: "390x844@3",
}),
}));
describe("subscription payment dialogs", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
mocks.submitFeedback.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.querySelectorAll('[role="dialog"]').forEach((node) => node.remove());
});
it("confirms a VIP plan without submitting payment", () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
act(() =>
root.render(
<SubscriptionRenewalConfirmationDialog
open
plan={{
id: "vip_monthly",
title: "Monthly",
price: "19.90",
currency: "usd",
originalPrice: "39.90",
}}
onCancel={onCancel}
onConfirm={onConfirm}
/>,
),
);
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("Automatic Renewal Confirmation");
expect(dialog?.textContent).toContain("Monthly");
expect(dialog?.textContent).toContain("19.90 usd");
expect(dialog?.querySelectorAll("a")).toHaveLength(2);
act(() => clickButton(dialog, "Confirm"));
expect(onConfirm).toHaveBeenCalledOnce();
expect(onCancel).not.toHaveBeenCalled();
});
it("submits Other as a Manager payment issue with one description field", async () => {
mocks.submitFeedback.mockResolvedValue(
Result.ok({ feedbackId: "feedback-payment-1" }),
);
const onClose = vi.fn();
const onSubmitted = vi.fn();
act(() =>
root.render(
<SubscriptionPaymentIssueDialog
open
subscriptionType="vip"
planId="vip_monthly"
orderId={null}
payChannel="ezpay"
countryCode="ID"
characterId="maya"
onClose={onClose}
onSubmitted={onSubmitted}
/>,
),
);
const other = document.body.querySelector<HTMLInputElement>(
'input[value="other"]',
);
expect(
Array.from(document.body.querySelectorAll<HTMLInputElement>('input[type="radio"]')).map(
(input) => input.parentElement?.textContent?.trim(),
),
).toEqual([
"Concerned about card information security",
"Insufficient card or wallet balance",
"No supported payment method available",
"Too expensive",
"Other",
]);
act(() => {
other?.click();
});
const textarea = document.body.querySelector("textarea");
expect(textarea?.getAttribute("placeholder")).toBe(
"Please describe the payment problem you encountered.",
);
act(() => setTextareaValue(textarea, "QRIS did not open correctly."));
await act(async () => {
clickButton(document.body, "Submit");
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.submitFeedback).toHaveBeenCalledWith({
category: "payment",
content: "Payment issue: Other\nDetails: QRIS did not open correctly.",
context: {
appVersion: "test",
platform: "web",
browser: "Chrome",
viewport: "390x844@3",
sourcePage: "subscription",
paymentIssueReason: "other",
subscriptionType: "vip",
planId: "vip_monthly",
payChannel: "ezpay",
countryCode: "ID",
characterId: "maya",
},
images: [],
idempotencyKey: expect.stringMatching(/^payment_feedback_[A-Za-z0-9_-]+$/),
});
expect(onSubmitted).toHaveBeenCalledWith(
"Thanks. Your payment issue has been submitted.",
);
expect(onClose).toHaveBeenCalledOnce();
});
it("keeps a failed payment issue open and reuses its idempotency key for retry", async () => {
mocks.submitFeedback
.mockResolvedValueOnce(Result.err(new Error("backend unavailable")))
.mockResolvedValueOnce(Result.ok({ feedbackId: "feedback-payment-2" }));
const onClose = vi.fn();
act(() =>
root.render(
<SubscriptionPaymentIssueDialog
open
subscriptionType="topup"
planId="coin_1000"
orderId="pay_pending_1"
payChannel="stripe"
countryCode="US"
characterId="elio"
onClose={onClose}
onSubmitted={() => undefined}
/>,
),
);
const reason = document.body.querySelector<HTMLInputElement>(
'input[value="tooExpensive"]',
);
act(() => reason?.click());
await act(async () => {
clickButton(document.body, "Submit");
await Promise.resolve();
await Promise.resolve();
});
expect(document.body.textContent).toContain(
"Unable to submit. Please try again.",
);
expect(onClose).not.toHaveBeenCalled();
await act(async () => {
clickButton(document.body, "Submit");
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.submitFeedback).toHaveBeenCalledTimes(2);
expect(mocks.submitFeedback.mock.calls[0]?.[0].idempotencyKey).toBe(
mocks.submitFeedback.mock.calls[1]?.[0].idempotencyKey,
);
expect(onClose).toHaveBeenCalledOnce();
});
});
function clickButton(root: ParentNode | null, label: string): void {
const button = Array.from(root?.querySelectorAll("button") ?? []).find(
(item) => item.textContent?.trim() === label,
);
if (!button) throw new Error(`Expected ${label} button`);
button.click();
}
function setTextareaValue(
element: HTMLTextAreaElement | null,
value: string,
): void {
if (!element) throw new Error("Expected textarea");
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
setter?.call(element, value);
element.dispatchEvent(new Event("input", { bubbles: true }));
}