import { afterEach, describe, expect, it, vi } from "vitest"; import { createActor, waitFor } from "xstate"; import { behaviorAnalytics } from "@/lib/analytics"; import { MAX_ORDER_POLLING_MS, PAYMENT_TIMEOUT_ERROR_MESSAGE, } from "@/stores/payment/helper"; import { createTestPaymentMachine, creditPlan, type CreateOrderSpy, } from "./payment-machine.test-utils"; describe("payment order flow", () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it("does not create an order before agreement is accepted", async () => { const createOrderSpy = vi.fn(); const actor = createActor( createTestPaymentMachine({ createOrderSpy }), ).start(); await initialize(actor); actor.send({ type: "PaymentAgreementChanged", agreed: false }); actor.send({ type: "PaymentCreateOrderSubmitted" }); expect(actor.getSnapshot().matches("ready")).toBe(true); expect(actor.getSnapshot().context.errorMessage).toBe( "Choose a plan and accept the agreement before continuing.", ); expect(createOrderSpy).not.toHaveBeenCalled(); actor.stop(); }); it("creates an order, polls it, and reaches paid", async () => { const createOrderStart = vi .spyOn(behaviorAnalytics, "createOrderStart") .mockImplementation(() => undefined); const createOrderSuccess = vi .spyOn(behaviorAnalytics, "createOrderSuccess") .mockImplementation(() => undefined); const statusPollStart = vi .spyOn(behaviorAnalytics, "statusPollStart") .mockImplementation(() => undefined); const statusPollUpdate = vi .spyOn(behaviorAnalytics, "statusPollUpdate") .mockImplementation(() => undefined); const createOrderSpy = vi.fn(); const actor = createActor( createTestPaymentMachine({ createOrderSpy }), ).start(); await initialize(actor); actor.send({ type: "PaymentCreateOrderSubmitted" }); await waitFor(actor, (snapshot) => snapshot.matches("paid")); expect(createOrderSpy).toHaveBeenCalledWith({ planId: "vip_monthly", payChannel: "stripe", autoRenew: true, }); expect(actor.getSnapshot().context).toMatchObject({ currentOrderId: "pay_test_001", orderStatus: "paid", tipMessage: null, tipMessageError: null, orderPollingStartedAt: null, launchNonce: 1, }); expect(createOrderStart).toHaveBeenCalledOnce(); expect(createOrderSuccess).toHaveBeenCalledOnce(); expect(statusPollStart).toHaveBeenCalledOnce(); expect(statusPollUpdate).toHaveBeenCalledWith( "pay_test_001", "paid", expect.objectContaining({ planId: "vip_monthly" }), ); actor.stop(); }); it("loads a stable Tip message after payment and clears it on reset", async () => { const loadTipMessage = vi.fn(); const actor = createActor( createTestPaymentMachine({ orderType: "tip", orderPlanId: "tip_coffee_usd_9_99", onLoadTipMessage: loadTipMessage, tipMessage: { orderId: "pay_test_001", characterId: "elio", planId: "tip_coffee_usd_9_99", productName: "Golden Reserve", tipCount: 2, poolIndex: 18, message: "You made my day.", }, }), ).start(); await initializeTip(actor); actor.send({ type: "PaymentCreateOrderSubmitted", recipientCharacterId: "elio", }); await waitFor(actor, (snapshot) => snapshot.matches("paid")); expect(loadTipMessage).toHaveBeenCalledWith("pay_test_001"); expect(actor.getSnapshot().context).toMatchObject({ orderStatus: "paid", tipMessage: expect.objectContaining({ tipCount: 2, message: "You made my day.", }), tipMessageError: null, }); actor.send({ type: "PaymentReset" }); await waitFor(actor, (snapshot) => snapshot.matches("ready")); expect(actor.getSnapshot().context).toMatchObject({ tipMessage: null, tipMessageError: null, }); actor.stop(); }); it("clears the previous Tip result before creating another order", async () => { const actor = createActor( createTestPaymentMachine({ orderType: "tip", orderPlanId: "tip_coffee_usd_4_99", }), ).start(); await initializeTip(actor); actor.send({ type: "PaymentCreateOrderSubmitted" }); await waitFor(actor, (snapshot) => snapshot.matches("paid")); actor.send({ type: "PaymentCreateOrderSubmitted" }); expect(actor.getSnapshot().matches("creatingOrder")).toBe(true); expect(actor.getSnapshot().context).toMatchObject({ tipMessage: null, tipMessageError: null, }); actor.stop(); }); it("clears the previous Tip result when the payment channel changes", async () => { const actor = createActor( createTestPaymentMachine({ orderType: "tip", orderPlanId: "tip_coffee_usd_4_99", }), ).start(); await initializeTip(actor); actor.send({ type: "PaymentCreateOrderSubmitted" }); await waitFor(actor, (snapshot) => snapshot.matches("paid")); actor.send({ type: "PaymentPayChannelChanged", payChannel: "ezpay" }); expect(actor.getSnapshot().matches("ready")).toBe(true); expect(actor.getSnapshot().context).toMatchObject({ payChannel: "ezpay", tipMessage: null, tipMessageError: null, }); actor.stop(); }); it("passes the selected tip recipient to order creation", async () => { const createOrderSpy = vi.fn(); const actor = createActor( createTestPaymentMachine({ createOrderSpy }), ).start(); await initialize(actor); actor.send({ type: "PaymentCreateOrderSubmitted", recipientCharacterId: "maya-tan", }); await waitFor(actor, (snapshot) => snapshot.matches("paid")); expect(createOrderSpy).toHaveBeenCalledWith({ planId: "vip_monthly", payChannel: "stripe", autoRenew: true, recipientCharacterId: "maya-tan", }); actor.stop(); }); it("tracks order creation failures", async () => { const createOrderFailed = vi .spyOn(behaviorAnalytics, "createOrderFailed") .mockImplementation(() => undefined); const actor = createActor( createTestPaymentMachine({ createOrderError: new Error("backend unavailable"), }), ).start(); await initialize(actor); actor.send({ type: "PaymentCreateOrderSubmitted" }); await waitFor(actor, (snapshot) => snapshot.matches("failed")); expect(createOrderFailed).toHaveBeenCalledOnce(); expect(actor.getSnapshot().context.errorMessage).toBe( "backend unavailable", ); actor.stop(); }); it("waits for pending payment and can reset", async () => { const actor = createActor( createTestPaymentMachine({ orderStatus: "pending" }), ).start(); await initialize(actor); actor.send({ type: "PaymentCreateOrderSubmitted" }); await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment")); expect(actor.getSnapshot().context.orderStatus).toBe("pending"); actor.send({ type: "PaymentReset" }); await waitFor(actor, (snapshot) => snapshot.matches("ready")); expect(actor.getSnapshot().context).toMatchObject({ currentOrderId: null, payParams: null, orderStatus: null, tipMessage: null, tipMessageError: null, orderPollingStartedAt: null, errorMessage: null, }); actor.stop(); }); it("moves to failed when polling reports failure", async () => { const actor = createActor( createTestPaymentMachine({ orderStatus: "failed" }), ).start(); await initialize(actor); actor.send({ type: "PaymentCreateOrderSubmitted" }); await waitFor(actor, (snapshot) => snapshot.matches("failed")); expect(actor.getSnapshot().context).toMatchObject({ orderStatus: "failed", orderPollingStartedAt: null, errorMessage: "Payment failed or was cancelled.", }); actor.stop(); }); it("moves expired orders to a dedicated terminal state", async () => { const actor = createActor( createTestPaymentMachine({ orderStatus: "expired" }), ).start(); await initialize(actor); actor.send({ type: "PaymentCreateOrderSubmitted" }); await waitFor(actor, (snapshot) => snapshot.matches("expired")); expect(actor.getSnapshot().context).toMatchObject({ orderStatus: "expired", payParams: { provider: "stripe", clientSecret: "pi_test_secret_test", }, errorMessage: "This payment order has expired. Please create a new order.", }); actor.stop(); }); it("keeps paid state when the Tip message fails and retries only the message", async () => { const loadTipMessage = vi.fn(); const actor = createActor( createTestPaymentMachine({ orderType: "tip", orderPlanId: "tip_coffee_usd_4_99", tipMessageError: new Error("message unavailable"), onLoadTipMessage: loadTipMessage, }), ).start(); await initializeTip(actor); actor.send({ type: "PaymentCreateOrderSubmitted" }); await waitFor(actor, (snapshot) => snapshot.matches("tipMessageFailed")); expect(actor.getSnapshot().context).toMatchObject({ orderStatus: "paid", tipMessage: null, tipMessageError: "message unavailable", }); actor.send({ type: "PaymentTipMessageRetryRequested" }); await waitFor(actor, () => loadTipMessage.mock.calls.length === 2); expect(actor.getSnapshot().context.orderStatus).toBe("paid"); actor.stop(); }); it("times out an old pending order", async () => { const statusPollTimeout = vi .spyOn(behaviorAnalytics, "statusPollTimeout") .mockImplementation(() => undefined); const now = new Date("2026-06-30T00:00:00.000Z"); vi.useFakeTimers(); vi.setSystemTime(now); const actor = createActor( createTestPaymentMachine({ orderStatus: "pending" }), ).start(); await initialize(actor); actor.send({ type: "PaymentCreateOrderSubmitted" }); await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment")); vi.setSystemTime(now.getTime() + MAX_ORDER_POLLING_MS); await vi.advanceTimersByTimeAsync(4000); await waitFor(actor, (snapshot) => snapshot.matches("failed")); expect(actor.getSnapshot().context.errorMessage).toBe( PAYMENT_TIMEOUT_ERROR_MESSAGE, ); expect(statusPollTimeout).toHaveBeenCalledOnce(); actor.stop(); }); it("prioritizes paid response over timeout", async () => { const now = new Date("2026-06-30T00:00:00.000Z"); vi.useFakeTimers(); vi.setSystemTime(now); const actor = createActor( createTestPaymentMachine({ orderStatuses: ["pending", "paid"] }), ).start(); await initialize(actor); actor.send({ type: "PaymentCreateOrderSubmitted" }); await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment")); vi.setSystemTime(now.getTime() + MAX_ORDER_POLLING_MS); await vi.advanceTimersByTimeAsync(4000); await waitFor(actor, (snapshot) => snapshot.matches("paid")); expect(actor.getSnapshot().context.errorMessage).toBeNull(); actor.stop(); }); it("uses restored order creation time", async () => { const now = new Date("2026-06-30T00:00:00.000Z"); vi.useFakeTimers(); vi.setSystemTime(now); const actor = createActor( createTestPaymentMachine({ orderStatus: "pending" }), ).start(); await initialize(actor); actor.send({ type: "PaymentReturned", orderId: "pay_returned_001", createdAt: now.getTime() - MAX_ORDER_POLLING_MS, }); await waitFor(actor, (snapshot) => snapshot.matches("failed")); expect(actor.getSnapshot().context).toMatchObject({ currentOrderId: "pay_returned_001", orderStatus: "failed", errorMessage: PAYMENT_TIMEOUT_ERROR_MESSAGE, }); actor.stop(); }); it("clears order data and disables renewal for lifetime plans", async () => { const actor = createActor(createTestPaymentMachine()).start(); await initialize(actor); actor.send({ type: "PaymentCreateOrderSubmitted" }); await waitFor(actor, (snapshot) => snapshot.matches("paid")); actor.send({ type: "PaymentPlanSelected", planId: "vip_lifetime" }); await waitFor(actor, (snapshot) => snapshot.matches("ready")); expect(actor.getSnapshot().context).toMatchObject({ selectedPlanId: "vip_lifetime", autoRenew: false, currentOrderId: null, payParams: null, orderStatus: null, }); actor.stop(); }); it("disables renewal for credit recharge plans", async () => { const actor = createActor(createTestPaymentMachine()).start(); await initialize(actor); actor.send({ type: "PaymentPlanSelected", planId: creditPlan.planId }); expect(actor.getSnapshot().context).toMatchObject({ selectedPlanId: creditPlan.planId, autoRenew: false, }); actor.stop(); }); }); async function initialize( actor: ReturnType>>, ): Promise { actor.send({ type: "PaymentInit" }); await waitFor(actor, (snapshot) => snapshot.matches("ready")); } async function initializeTip( actor: ReturnType>>, ): Promise { actor.send({ type: "PaymentInit", catalog: "tip", characterId: "elio" }); await waitFor(actor, (snapshot) => snapshot.matches("ready")); }