test(stores): add state machine coverage

This commit is contained in:
2026-06-25 13:28:08 +08:00
parent 0bff65dd32
commit 6081f2896a
8 changed files with 1081 additions and 2 deletions
@@ -0,0 +1,221 @@
import { describe, expect, it, vi } from "vitest";
import { createActor, fromPromise, waitFor } from "xstate";
import {
CreatePaymentOrderResponse,
type PayChannel,
PaymentOrderStatusResponse,
PaymentPlansResponse,
PaymentVipStatusResponse,
} from "@/data/dto/payment";
import { paymentMachine } from "@/stores/payment/payment-machine";
interface CreateOrderInput {
planId: string;
payChannel: PayChannel;
autoRenew: boolean;
}
type CreateOrderSpy = (input: CreateOrderInput) => void;
const monthlyPlan = {
plan_id: "vip_monthly",
plan_name: "VIP Monthly",
order_type: "vip_monthly",
amount_cents: 999,
original_amount_cents: null,
daily_price_cents: 33,
currency: "usd",
vip_days: 30,
dol_amount: null,
};
const lifetimePlan = {
plan_id: "vip_lifetime",
plan_name: "VIP Lifetime",
order_type: "vip_lifetime",
amount_cents: 4999,
original_amount_cents: null,
daily_price_cents: null,
currency: "usd",
vip_days: null,
dol_amount: null,
};
function createTestPaymentMachine(
overrides: Partial<{
createOrderSpy: CreateOrderSpy;
orderStatus: "pending" | "paid" | "failed";
}> = {},
) {
const createOrderSpy = overrides.createOrderSpy ?? vi.fn<CreateOrderSpy>();
const orderStatus = overrides.orderStatus ?? "paid";
return paymentMachine.provide({
actors: {
loadPlans: fromPromise(async () =>
PaymentPlansResponse.from({
plans: [monthlyPlan, lifetimePlan],
}),
),
createOrder: fromPromise<
CreatePaymentOrderResponse,
CreateOrderInput
>(async ({ input }) => {
createOrderSpy(input);
return CreatePaymentOrderResponse.from({
orderId: "pay_test_001",
payParams: {
provider: "stripe",
clientSecret: "pi_test_secret_test",
},
});
}),
pollOrderStatus: fromPromise(async () =>
PaymentOrderStatusResponse.from({
orderId: "pay_test_001",
status: orderStatus,
orderType: "vip_monthly",
planId: "vip_monthly",
}),
),
loadVipStatus: fromPromise(async () =>
PaymentVipStatusResponse.from({
isVip: true,
vipExpiresAt: "2026-07-25T00:00:00.000Z",
}),
),
},
});
}
describe("paymentMachine", () => {
it("loads plans and selects the default monthly VIP plan", async () => {
const actor = createActor(createTestPaymentMachine()).start();
actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
const context = actor.getSnapshot().context;
expect(context.plans).toHaveLength(2);
expect(context.selectedPlanId).toBe("vip_monthly");
expect(context.autoRenew).toBe(true);
expect(context.errorMessage).toBeNull();
actor.stop();
});
it("does not create an order before the agreement is accepted", async () => {
const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor(
createTestPaymentMachine({ createOrderSpy }),
).start();
actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PaymentAgreementChanged", agreed: false });
actor.send({ type: "PaymentCreateOrderSubmitted" });
const snapshot = actor.getSnapshot();
expect(snapshot.matches("ready")).toBe(true);
expect(snapshot.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 state", async () => {
const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor(
createTestPaymentMachine({ createOrderSpy }),
).start();
actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
const context = actor.getSnapshot().context;
expect(createOrderSpy).toHaveBeenCalledWith({
planId: "vip_monthly",
payChannel: "stripe",
autoRenew: true,
});
expect(context.currentOrderId).toBe("pay_test_001");
expect(context.orderStatus).toBe("paid");
expect(context.launchNonce).toBe(1);
expect(context.vipStatus?.isVip).toBe(true);
actor.stop();
});
it("moves to waiting state for pending orders and can reset order data", async () => {
const actor = createActor(
createTestPaymentMachine({ orderStatus: "pending" }),
).start();
actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment"));
expect(actor.getSnapshot().context.currentOrderId).toBe("pay_test_001");
expect(actor.getSnapshot().context.orderStatus).toBe("pending");
actor.send({ type: "PaymentReset" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
const context = actor.getSnapshot().context;
expect(context.currentOrderId).toBeNull();
expect(context.payParams).toBeNull();
expect(context.orderStatus).toBeNull();
expect(context.errorMessage).toBeNull();
actor.stop();
});
it("moves to failed state when polling returns failed", async () => {
const actor = createActor(
createTestPaymentMachine({ orderStatus: "failed" }),
).start();
actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("failed"));
const context = actor.getSnapshot().context;
expect(context.orderStatus).toBe("failed");
expect(context.errorMessage).toBe("Payment failed or was cancelled.");
actor.stop();
});
it("clears stale order data and disables auto renew for lifetime plans", async () => {
const actor = createActor(createTestPaymentMachine()).start();
actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
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"));
const context = actor.getSnapshot().context;
expect(context.selectedPlanId).toBe("vip_lifetime");
expect(context.autoRenew).toBe(false);
expect(context.currentOrderId).toBeNull();
expect(context.payParams).toBeNull();
expect(context.orderStatus).toBeNull();
actor.stop();
});
});