Files
cfw-autumn/server/tests/integration/billing/restore/restore-basic.test.ts
2026-05-05 18:06:39 +08:00

185 lines
6.8 KiB
TypeScript

/**
* Restore Basic Tests
*
* The `restore` billing action takes Autumn's customer_products as the source
* of truth and reshapes Stripe to match. Tests here drift Stripe out of sync
* deliberately, then verify restore brings it back.
*
* Test 1: Two entities (pro + premium). Corrupt entity 1's sub items, restore.
* Test 2: Schedule scenario — release schedule manually, restore recreates it.
*/
import { expect, test } from "bun:test";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { CusService } from "@/internal/customers/CusService";
import { ProductService } from "@/internal/products/ProductService";
import { expectStripeSubscriptionCorrect } from "../utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect";
import {
corruptStripeSubscription,
listActiveStripeSubscriptions,
} from "./utils/corruptStripeSubscription";
const stripeCustomerIdFor = async ({
ctx,
customerId,
}: {
ctx: TestContext;
customerId: string;
}) => {
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const stripeCustomerId = fullCustomer.processor?.id;
if (!stripeCustomerId) {
throw new Error(`Customer ${customerId} has no Stripe customer ID`);
}
return stripeCustomerId;
};
const firstStripePriceIdFor = async ({
ctx,
productId,
}: {
ctx: TestContext;
productId: string;
}) => {
const fullProduct = await ProductService.getFull({
db: ctx.db,
idOrInternalId: productId,
orgId: ctx.org.id,
env: ctx.env,
});
for (const price of fullProduct.prices) {
const id =
price.config.stripe_price_id ?? price.config.stripe_empty_price_id;
if (id) return id;
}
throw new Error(`No Stripe price id on product ${productId}`);
};
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Two entities, corrupt one sub, restore
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("restore-basic 1: two entities pro+premium, corrupt one, restore")}`, async () => {
const customerId = "restore-basic-two-entities";
const proMessages = items.monthlyMessages({ includedUsage: 200 });
const pro = products.pro({ id: "pro", items: [proMessages] });
const premiumMessages = items.monthlyMessages({ includedUsage: 1000 });
const premium = products.premium({ id: "premium", items: [premiumMessages] });
const { autumnV2_2, ctx, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.billing.attach({ productId: pro.id, entityIndex: 0 }),
s.billing.attach({
productId: premium.id,
entityIndex: 1,
timeout: 4000,
}),
],
});
expect(entities.length).toBe(2);
// Corrupt the pro sub item by setting its quantity to a wrong value. Each
// entity may share a single Stripe subscription (entity-scoped items), so we
// avoid mutations that depend on the sub topology — a per-item quantity bump
// is a clean diff restore should reverse.
const stripeCustomerId = await stripeCustomerIdFor({ ctx, customerId });
const subs = await listActiveStripeSubscriptions({ ctx, stripeCustomerId });
const proStripePriceId = await firstStripePriceIdFor({
ctx,
productId: pro.id,
});
const proSub = subs.find((sub) =>
sub.items.data.some((it) => it.price.id === proStripePriceId),
);
if (!proSub) throw new Error("Pro sub not found in Stripe");
await corruptStripeSubscription({
ctx,
subscriptionId: proSub.id,
mutations: {
setItemQuantities: [{ priceId: proStripePriceId, quantity: 7 }],
},
});
// Restore — should remove the junk item.
await autumnV2_2.billing.restore({ customer_id: customerId });
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: With schedule — release schedule manually, restore recreates it
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("restore-basic 2: scheduled downgrade, schedule released externally, restore recreates")}`, async () => {
const customerId = "restore-basic-released-schedule";
const proMessages = items.monthlyMessages({ includedUsage: 200 });
const pro = products.pro({ id: "pro", items: [proMessages] });
const premiumMessages = items.monthlyMessages({ includedUsage: 1000 });
const premium = products.premium({ id: "premium", items: [premiumMessages] });
const { autumnV2_2, ctx, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.billing.attach({ productId: pro.id, entityIndex: 0 }),
s.billing.attach({ productId: premium.id, entityIndex: 1 }),
// Schedule downgrade premium → pro on entity 1
s.billing.attach({
productId: pro.id,
entityIndex: 1,
timeout: 4000,
}),
],
});
expect(entities.length).toBe(2);
// Locate the entity-1 (premium) Stripe subscription which now has a schedule
const stripeCustomerId = await stripeCustomerIdFor({ ctx, customerId });
const subs = await listActiveStripeSubscriptions({ ctx, stripeCustomerId });
const premiumStripePriceId = await firstStripePriceIdFor({
ctx,
productId: premium.id,
});
const premiumSub = subs.find((sub) =>
sub.items.data.some((it) => it.price.id === premiumStripePriceId),
);
if (!premiumSub) throw new Error("Premium sub not found");
// Release the schedule directly via Stripe (corrupts state)
await corruptStripeSubscription({
ctx,
subscriptionId: premiumSub.id,
mutations: { releaseSchedule: true },
});
// Restore — should re-create the schedule
await autumnV2_2.billing.restore({ customer_id: customerId });
await expectStripeSubscriptionCorrect({ ctx, customerId });
});