refactor(stores): standardize state machine structure
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
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("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"));
|
||||
}
|
||||
Reference in New Issue
Block a user