refactor(stores): standardize state machine structure

This commit is contained in:
2026-07-15 11:19:08 +08:00
parent ed3e800245
commit 20d07023e4
68 changed files with 3492 additions and 3193 deletions
@@ -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"));
}