261 lines
8.7 KiB
TypeScript
261 lines
8.7 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",
|
|
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<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,
|
|
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<typeof createActor<ReturnType<typeof createTestPaymentMachine>>>,
|
|
): Promise<void> {
|
|
actor.send({ type: "PaymentInit" });
|
|
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
|
}
|