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( , ), ); 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( , ), ); const other = document.body.querySelector( 'input[value="other"]', ); expect( Array.from(document.body.querySelectorAll('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( undefined} />, ), ); const reason = document.body.querySelector( '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 })); }