258 lines
7.6 KiB
TypeScript
258 lines
7.6 KiB
TypeScript
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 { ApiError } from "@/data/services/api/api_result";
|
|
|
|
import {
|
|
getPaymentIssueSubmitErrorLogDetails,
|
|
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(
|
|
Array.from(dialog?.querySelectorAll("a") ?? []).map((link) =>
|
|
link.getAttribute("href"),
|
|
),
|
|
).toEqual([
|
|
"/legal/vip-membership-benefits.html",
|
|
"/legal/automatic-renewal.html",
|
|
]);
|
|
|
|
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();
|
|
});
|
|
|
|
it("keeps HTTP status and backend error code in payment issue failure diagnostics", () => {
|
|
const failure = Result.err(
|
|
new ApiError("HTTP_ERROR", "Invalid feedback context", 400, {
|
|
detail: {
|
|
message: "Invalid feedback context",
|
|
errorCode: "INVALID_FEEDBACK_CONTEXT",
|
|
},
|
|
}),
|
|
);
|
|
|
|
if (failure.success) throw new Error("Expected failure result");
|
|
expect(getPaymentIssueSubmitErrorLogDetails(failure.error)).toMatchObject({
|
|
code: "UNKNOWN",
|
|
message: "Invalid feedback context",
|
|
causeName: "ApiError",
|
|
causeMessage: "Invalid feedback context",
|
|
httpStatus: 400,
|
|
apiCode: "HTTP_ERROR",
|
|
serverErrorCode: "INVALID_FEEDBACK_CONTEXT",
|
|
});
|
|
});
|
|
});
|
|
|
|
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 }));
|
|
}
|