test(stores): add state machine coverage
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user