feat(tip): support dynamic gift products

This commit is contained in:
2026-07-21 13:19:45 +08:00
parent 55cb98ed14
commit 37ff69020b
62 changed files with 2325 additions and 1085 deletions
@@ -65,8 +65,8 @@ describe("payment order flow", () => {
expect(actor.getSnapshot().context).toMatchObject({
currentOrderId: "pay_test_001",
orderStatus: "paid",
tipCount: null,
thankYouMessage: null,
tipMessage: null,
tipMessageError: null,
orderPollingStartedAt: null,
launchNonce: 1,
});
@@ -81,33 +81,46 @@ describe("payment order flow", () => {
actor.stop();
});
it("stores a stable Tip success result and clears it on reset", async () => {
it("loads a stable Tip message after payment and clears it on reset", async () => {
const loadTipMessage = vi.fn();
const actor = createActor(
createTestPaymentMachine({
orderType: "tip",
orderPlanId: "tip_coffee_usd_9_99",
tipCount: 2,
thankYouMessage: "You made my day.",
onLoadTipMessage: loadTipMessage,
tipMessage: {
orderId: "pay_test_001",
characterId: "elio",
planId: "tip_coffee_usd_9_99",
productName: "Golden Reserve",
tipCount: 2,
poolIndex: 18,
message: "You made my day.",
},
}),
).start();
await initialize(actor);
await initializeTip(actor);
actor.send({
type: "PaymentCreateOrderSubmitted",
recipientCharacterId: "maya-tan",
recipientCharacterId: "elio",
});
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
expect(loadTipMessage).toHaveBeenCalledWith("pay_test_001");
expect(actor.getSnapshot().context).toMatchObject({
orderStatus: "paid",
tipCount: 2,
thankYouMessage: "You made my day.",
tipMessage: expect.objectContaining({
tipCount: 2,
message: "You made my day.",
}),
tipMessageError: null,
});
actor.send({ type: "PaymentReset" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context).toMatchObject({
tipCount: null,
thankYouMessage: null,
tipMessage: null,
tipMessageError: null,
});
actor.stop();
});
@@ -116,19 +129,18 @@ describe("payment order flow", () => {
const actor = createActor(
createTestPaymentMachine({
orderType: "tip",
tipCount: 3,
thankYouMessage: "Another warm coffee.",
orderPlanId: "tip_coffee_usd_4_99",
}),
).start();
await initialize(actor);
await initializeTip(actor);
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
actor.send({ type: "PaymentCreateOrderSubmitted" });
expect(actor.getSnapshot().matches("creatingOrder")).toBe(true);
expect(actor.getSnapshot().context).toMatchObject({
tipCount: null,
thankYouMessage: null,
tipMessage: null,
tipMessageError: null,
});
actor.stop();
});
@@ -137,11 +149,10 @@ describe("payment order flow", () => {
const actor = createActor(
createTestPaymentMachine({
orderType: "tip",
tipCount: 4,
thankYouMessage: "That was so thoughtful.",
orderPlanId: "tip_coffee_usd_4_99",
}),
).start();
await initialize(actor);
await initializeTip(actor);
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
@@ -149,8 +160,8 @@ describe("payment order flow", () => {
expect(actor.getSnapshot().matches("ready")).toBe(true);
expect(actor.getSnapshot().context).toMatchObject({
payChannel: "ezpay",
tipCount: null,
thankYouMessage: null,
tipMessage: null,
tipMessageError: null,
});
actor.stop();
});
@@ -211,8 +222,8 @@ describe("payment order flow", () => {
currentOrderId: null,
payParams: null,
orderStatus: null,
tipCount: null,
thankYouMessage: null,
tipMessage: null,
tipMessageError: null,
orderPollingStartedAt: null,
errorMessage: null,
});
@@ -235,6 +246,48 @@ describe("payment order flow", () => {
actor.stop();
});
it("moves expired orders to a dedicated terminal state", async () => {
const actor = createActor(
createTestPaymentMachine({ orderStatus: "expired" }),
).start();
await initialize(actor);
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("expired"));
expect(actor.getSnapshot().context).toMatchObject({
orderStatus: "expired",
payParams: null,
errorMessage:
"This payment order has expired. Please create a new order.",
});
actor.stop();
});
it("keeps paid state when the Tip message fails and retries only the message", async () => {
const loadTipMessage = vi.fn();
const actor = createActor(
createTestPaymentMachine({
orderType: "tip",
orderPlanId: "tip_coffee_usd_4_99",
tipMessageError: new Error("message unavailable"),
onLoadTipMessage: loadTipMessage,
}),
).start();
await initializeTip(actor);
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("tipMessageFailed"));
expect(actor.getSnapshot().context).toMatchObject({
orderStatus: "paid",
tipMessage: null,
tipMessageError: "message unavailable",
});
actor.send({ type: "PaymentTipMessageRetryRequested" });
await waitFor(actor, () => loadTipMessage.mock.calls.length === 2);
expect(actor.getSnapshot().context.orderStatus).toBe("paid");
actor.stop();
});
it("times out an old pending order", async () => {
const statusPollTimeout = vi
.spyOn(behaviorAnalytics, "statusPollTimeout")
@@ -336,3 +389,10 @@ async function initialize(
actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
}
async function initializeTip(
actor: ReturnType<typeof createActor<ReturnType<typeof createTestPaymentMachine>>>,
): Promise<void> {
actor.send({ type: "PaymentInit", catalog: "tip", characterId: "elio" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
}