344 lines
9.6 KiB
TypeScript
344 lines
9.6 KiB
TypeScript
/**
|
|
* Unit tests for handleExternalPSPErrors (V2 attach + update gate).
|
|
*
|
|
* Key invariants:
|
|
* - On `update`, fail when the targeted cusProduct is RC-managed.
|
|
* - On `attach`, scan ALL customer_products for non-Stripe processors.
|
|
* - On `attach`, bypass ONLY when attaching a true one-off (every price has
|
|
* interval === OneOff). Recurring add-ons take the strict path.
|
|
*/
|
|
|
|
import { describe, expect, test } from "bun:test";
|
|
import {
|
|
BillingInterval,
|
|
type FullCusProduct,
|
|
type FullProduct,
|
|
PriceType,
|
|
ProcessorType,
|
|
type RecaseError,
|
|
} from "@autumn/shared";
|
|
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
|
|
import { prices as priceFixtures } from "@tests/utils/fixtures/db/prices";
|
|
import { products as productFixtures } from "@tests/utils/fixtures/db/products";
|
|
import chalk from "chalk";
|
|
import { handleExternalPSPErrors } from "@/internal/billing/v2/common/errors/handleExternalPSPErrors";
|
|
|
|
const expectThrows = (fn: () => unknown, messageMatch: string | RegExp) => {
|
|
let caught: unknown;
|
|
try {
|
|
fn();
|
|
} catch (err) {
|
|
caught = err;
|
|
}
|
|
expect(caught).toBeDefined();
|
|
const err = caught as RecaseError;
|
|
if (typeof messageMatch === "string") {
|
|
expect(err.message).toContain(messageMatch);
|
|
} else {
|
|
expect(err.message).toMatch(messageMatch);
|
|
}
|
|
};
|
|
|
|
const buildOneOffProduct = (id: string, isAddOn = true): FullProduct =>
|
|
productFixtures.createFull({
|
|
id,
|
|
name: id,
|
|
isAddOn,
|
|
prices: [priceFixtures.createOneOff({ id: `pr_${id}` })],
|
|
});
|
|
|
|
const buildRecurringProduct = (id: string, isAddOn = false): FullProduct =>
|
|
productFixtures.createFull({
|
|
id,
|
|
name: id,
|
|
isAddOn,
|
|
prices: [priceFixtures.createFixed({ id: `pr_${id}` })],
|
|
});
|
|
|
|
const buildMixedIntervalProduct = (id: string): FullProduct =>
|
|
productFixtures.createFull({
|
|
id,
|
|
name: id,
|
|
isAddOn: true,
|
|
prices: [
|
|
priceFixtures.createOneOff({ id: `pr_${id}_oneoff` }),
|
|
// A recurring price alongside a one-off → not "only one off"
|
|
{
|
|
id: `pr_${id}_monthly`,
|
|
internal_product_id: "prod_internal",
|
|
org_id: "org_test",
|
|
created_at: Date.now(),
|
|
billing_type: "fixed_cycle",
|
|
is_custom: false,
|
|
entitlement_id: null,
|
|
proration_config: null,
|
|
config: {
|
|
type: PriceType.Fixed,
|
|
amount: 50,
|
|
interval: BillingInterval.Month,
|
|
stripe_price_id: `stripe_price_${id}_monthly`,
|
|
},
|
|
} as FullProduct["prices"][number],
|
|
],
|
|
});
|
|
|
|
const buildRcCusProduct = (id = "cus_prod_rc"): FullCusProduct => {
|
|
const product = buildRecurringProduct("rc_main", false);
|
|
return customerProducts.create({
|
|
id,
|
|
productId: "rc_main",
|
|
product,
|
|
customerPrices: product.prices.map((price) =>
|
|
priceFixtures.createCustomer({ price, customerProductId: id }),
|
|
),
|
|
processorType: ProcessorType.RevenueCat,
|
|
subscriptionIds: [],
|
|
});
|
|
};
|
|
|
|
/** RC-managed cus product whose underlying product has only one-off prices. */
|
|
const buildRcOneOffCusProduct = (id = "cus_prod_rc_oneoff"): FullCusProduct => {
|
|
const product = buildOneOffProduct("rc_oneoff", true);
|
|
return customerProducts.create({
|
|
id,
|
|
productId: "rc_oneoff",
|
|
product,
|
|
customerPrices: product.prices.map((price) =>
|
|
priceFixtures.createCustomer({ price, customerProductId: id }),
|
|
),
|
|
processorType: ProcessorType.RevenueCat,
|
|
subscriptionIds: [],
|
|
});
|
|
};
|
|
|
|
const buildStripeCusProduct = (id = "cus_prod_stripe"): FullCusProduct =>
|
|
customerProducts.create({
|
|
id,
|
|
productId: "stripe_main",
|
|
processorType: ProcessorType.Stripe,
|
|
subscriptionIds: ["sub_xyz"],
|
|
});
|
|
|
|
describe(
|
|
chalk.yellowBright("handleExternalPSPErrors v2 - update action"),
|
|
() => {
|
|
test("throws when updating an RC-managed cusProduct", () => {
|
|
const cp = buildRcCusProduct();
|
|
expectThrows(
|
|
() =>
|
|
handleExternalPSPErrors({
|
|
customerProduct: cp,
|
|
action: "update",
|
|
}),
|
|
"managed by RevenueCat",
|
|
);
|
|
});
|
|
|
|
test("does not throw when updating a Stripe-managed cusProduct", () => {
|
|
const cp = buildStripeCusProduct();
|
|
expect(() =>
|
|
handleExternalPSPErrors({
|
|
customerProduct: cp,
|
|
action: "update",
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("does not throw when no cusProduct is provided", () => {
|
|
expect(() =>
|
|
handleExternalPSPErrors({ action: "update" }),
|
|
).not.toThrow();
|
|
});
|
|
},
|
|
);
|
|
|
|
describe(
|
|
chalk.yellowBright("handleExternalPSPErrors v2 - attach action"),
|
|
() => {
|
|
test("BYPASS: attaching a one-off product when customer has RC main", () => {
|
|
const rc = buildRcCusProduct();
|
|
const oneOff = buildOneOffProduct("topup_25", true);
|
|
|
|
expect(() =>
|
|
handleExternalPSPErrors({
|
|
customerProducts: [rc],
|
|
attachProduct: oneOff,
|
|
action: "attach",
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("THROWS: attaching a recurring add-on when customer has RC main", () => {
|
|
const rc = buildRcCusProduct();
|
|
const recurringAddOn = buildRecurringProduct("recurring_addon", true);
|
|
|
|
expectThrows(
|
|
() =>
|
|
handleExternalPSPErrors({
|
|
customerProducts: [rc],
|
|
attachProduct: recurringAddOn,
|
|
action: "attach",
|
|
}),
|
|
"managed by RevenueCat",
|
|
);
|
|
});
|
|
|
|
test("THROWS: attaching a main recurring product when customer has RC main", () => {
|
|
const rc = buildRcCusProduct();
|
|
const mainRecurring = buildRecurringProduct("pro_50_monthly", false);
|
|
|
|
expectThrows(
|
|
() =>
|
|
handleExternalPSPErrors({
|
|
customerProducts: [rc],
|
|
attachProduct: mainRecurring,
|
|
action: "attach",
|
|
}),
|
|
"managed by RevenueCat",
|
|
);
|
|
});
|
|
|
|
test("THROWS: attaching a product with mixed one-off + recurring prices when customer has RC main", () => {
|
|
const rc = buildRcCusProduct();
|
|
const mixed = buildMixedIntervalProduct("mixed");
|
|
|
|
expectThrows(
|
|
() =>
|
|
handleExternalPSPErrors({
|
|
customerProducts: [rc],
|
|
attachProduct: mixed,
|
|
action: "attach",
|
|
}),
|
|
"managed by RevenueCat",
|
|
);
|
|
});
|
|
|
|
test("BYPASS: customer has only Stripe-managed products, attaching a recurring add-on", () => {
|
|
const stripe = buildStripeCusProduct();
|
|
const recurringAddOn = buildRecurringProduct("recurring_addon", true);
|
|
|
|
expect(() =>
|
|
handleExternalPSPErrors({
|
|
customerProducts: [stripe],
|
|
attachProduct: recurringAddOn,
|
|
action: "attach",
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("BYPASS: empty customer_products list", () => {
|
|
const oneOff = buildOneOffProduct("topup_25", true);
|
|
|
|
expect(() =>
|
|
handleExternalPSPErrors({
|
|
customerProducts: [],
|
|
attachProduct: oneOff,
|
|
action: "attach",
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("Mixed customer products: throws on RC even if Stripe is also present, when attaching recurring", () => {
|
|
const rc = buildRcCusProduct("cus_prod_rc");
|
|
const stripe = buildStripeCusProduct("cus_prod_stripe");
|
|
const recurringAddOn = buildRecurringProduct("recurring_addon", true);
|
|
|
|
expectThrows(
|
|
() =>
|
|
handleExternalPSPErrors({
|
|
customerProducts: [stripe, rc],
|
|
attachProduct: recurringAddOn,
|
|
action: "attach",
|
|
}),
|
|
"managed by RevenueCat",
|
|
);
|
|
});
|
|
|
|
test("Mixed customer products: bypass on RC + Stripe customer when attaching one-off", () => {
|
|
const rc = buildRcCusProduct("cus_prod_rc");
|
|
const stripe = buildStripeCusProduct("cus_prod_stripe");
|
|
const oneOff = buildOneOffProduct("topup_25", true);
|
|
|
|
expect(() =>
|
|
handleExternalPSPErrors({
|
|
customerProducts: [stripe, rc],
|
|
attachProduct: oneOff,
|
|
action: "attach",
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("Defensive: throws when no attachProduct is provided but customer has RC", () => {
|
|
const rc = buildRcCusProduct();
|
|
|
|
expectThrows(
|
|
() =>
|
|
handleExternalPSPErrors({
|
|
customerProducts: [rc],
|
|
action: "attach",
|
|
}),
|
|
"managed by RevenueCat",
|
|
);
|
|
});
|
|
|
|
// ─── External one-off-only products are NOT a conflict ──────────────────
|
|
test("BYPASS: customer has only an RC ONE-OFF product, attaching a Stripe recurring", () => {
|
|
// RC one-off (e.g. an in-app topup) doesn't have a recurring sub —
|
|
// nothing to conflict with the new Stripe attach.
|
|
const rcOneOff = buildRcOneOffCusProduct();
|
|
const recurringMain = buildRecurringProduct("pro_25_monthly", false);
|
|
|
|
expect(() =>
|
|
handleExternalPSPErrors({
|
|
customerProducts: [rcOneOff],
|
|
attachProduct: recurringMain,
|
|
action: "attach",
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("BYPASS: customer has only an RC ONE-OFF product, attaching a Stripe recurring add-on", () => {
|
|
const rcOneOff = buildRcOneOffCusProduct();
|
|
const recurringAddOn = buildRecurringProduct("recurring_addon", true);
|
|
|
|
expect(() =>
|
|
handleExternalPSPErrors({
|
|
customerProducts: [rcOneOff],
|
|
attachProduct: recurringAddOn,
|
|
action: "attach",
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("THROWS: customer has BOTH RC recurring and RC one-off, attaching a Stripe recurring", () => {
|
|
// The one-off is benign but the recurring product still conflicts.
|
|
const rcRecurring = buildRcCusProduct("cus_prod_rc_recurring");
|
|
const rcOneOff = buildRcOneOffCusProduct("cus_prod_rc_oneoff");
|
|
const recurringMain = buildRecurringProduct("pro_50_monthly", false);
|
|
|
|
expectThrows(
|
|
() =>
|
|
handleExternalPSPErrors({
|
|
customerProducts: [rcOneOff, rcRecurring],
|
|
attachProduct: recurringMain,
|
|
action: "attach",
|
|
}),
|
|
"managed by RevenueCat",
|
|
);
|
|
});
|
|
|
|
test("BYPASS: customer has only an RC ONE-OFF, no attachProduct provided", () => {
|
|
// Defensive: even without attachProduct, an RC one-off shouldn't
|
|
// trigger the guard since there's no recurring conflict.
|
|
const rcOneOff = buildRcOneOffCusProduct();
|
|
|
|
expect(() =>
|
|
handleExternalPSPErrors({
|
|
customerProducts: [rcOneOff],
|
|
action: "attach",
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
},
|
|
);
|