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", 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("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, 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("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")); }