Files
cozsweet-frontend-nextjs/src/stores/payment/__tests__/payment-order-flow.test.ts
T

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