402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
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<CreateOrderSpy>();
|
|
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<CreateOrderSpy>();
|
|
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<CreateOrderSpy>();
|
|
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<typeof createActor<ReturnType<typeof createTestPaymentMachine>>>,
|
|
): Promise<void> {
|
|
actor.send({ type: "PaymentInit" });
|
|
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
|
}
|
|
|
|
async function initializeTip(
|
|
actor: ReturnType<typeof createActor<ReturnType<typeof createTestPaymentMachine>>>,
|
|
): Promise<void> {
|
|
actor.send({ type: "PaymentInit", catalog: "tip", characterId: "elio" });
|
|
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
|
}
|