185 lines
6.8 KiB
TypeScript
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 });
|
|
});
|