Files
cfw-autumn/server/tests/integration/billing/migrations/migrate-errors.test.ts
2026-02-08 13:54:33 -08:00

288 lines
9.4 KiB
TypeScript

/**
* Migration Error Tests
*
* Tests for migration error cases and validation.
* Ensures migrations fail gracefully with proper error messages.
*
* Key behaviors:
* - Free → Paid migration should error
* - One-off products cannot be migrated
* - Prepaid feature mismatches should error
* - Concurrent migrations for same product should error
*/
import { expect, test } from "bun:test";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
const _waitForMigration = (ms = 5000) =>
new Promise((resolve) => setTimeout(resolve, ms));
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Free to Paid Migration (Error)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has free product
* - Try to migrate to paid product
*
* Expected Result:
* - Error: Cannot migrate from free to paid
*/
test.concurrent(`${chalk.yellowBright("migrate-errors-1: free to paid migration should error")}`, async () => {
const customerId = "migrate-err-free-to-paid";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: "free" })],
});
// Try to migrate from free to paid - should error
await expectAutumnError({
func: async () => {
await autumnV1.migrate({
from_product_id: free.id,
to_product_id: pro.id,
from_version: 1,
to_version: 1,
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Migrate FROM One-Off Product (Error)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has one-off product
* - Try to migrate to another version
*
* Expected Result:
* - Error: Cannot migrate one-off products
*/
test.concurrent(`${chalk.yellowBright("migrate-errors-2: migrate from one-off product should error")}`, async () => {
const customerId = "migrate-err-from-oneoff";
const oneOff = products.oneOff({
id: "one-off",
items: [items.oneOffMessages({ includedUsage: 100 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOff] }),
],
actions: [s.billing.attach({ productId: "one-off" })],
});
// Update to v2
await autumnV1.products.update(oneOff.id, {
items: [items.oneOffMessages({ includedUsage: 200 })],
});
// Try to migrate one-off product - should error
await expectAutumnError({
func: async () => {
await autumnV1.migrate({
from_product_id: oneOff.id,
to_product_id: oneOff.id,
from_version: 1,
to_version: 2,
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Migrate TO One-Off Product (Error)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has pro product
* - Try to migrate to one-off product
*
* Expected Result:
* - Error: Cannot migrate to one-off products
*/
test.concurrent(`${chalk.yellowBright("migrate-errors-3: migrate to one-off product should error")}`, async () => {
const customerId = "migrate-err-to-oneoff";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const oneOff = products.oneOff({
id: "one-off",
items: [items.oneOffMessages({ includedUsage: 100 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, oneOff] }),
],
actions: [s.billing.attach({ productId: "pro" })],
});
// Try to migrate to one-off product - should error
await expectAutumnError({
func: async () => {
await autumnV1.migrate({
from_product_id: pro.id,
to_product_id: oneOff.id,
from_version: 1,
to_version: 1,
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: New Product Has Prepaid Feature Old Doesn't (Error)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has pro product with monthly messages (no prepaid)
* - Update product to v2 with prepaid messages
* - Try to migrate
*
* Expected Result:
* - Error: Can't perform migration (prepaid feature mismatch)
*/
test.concurrent(`${chalk.yellowBright("migrate-errors-4: new product has prepaid feature old doesn't")}`, async () => {
const customerId = "migrate-err-prepaid-mismatch";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.billing.attach({ productId: "pro" })],
});
// Update to v2 with prepaid messages instead of monthly
await autumnV1.products.update(pro.id, {
items: [items.prepaidMessages({ includedUsage: 0, billingUnits: 100 })],
});
// Try to migrate - should error due to prepaid mismatch
await expectAutumnError({
func: async () => {
await autumnV1.migrate({
from_product_id: pro.id,
to_product_id: pro.id,
from_version: 1,
to_version: 2,
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: Concurrent Migrations for Same Product (Error)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has pro v1
* - Update product to v2 and v3
* - Try to run two migrations concurrently (v1→v2 and v1→v3)
*
* Expected Result:
* - One should succeed, one should error with "migration is ongoing"
*/
test.concurrent(`${chalk.yellowBright("migrate-errors-5: concurrent migrations for same product should error")}`, async () => {
const customerId = "migrate-err-concurrent";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.billing.attach({ productId: "pro" })],
});
// Update to v2
await autumnV1.products.update(pro.id, {
items: [
items.monthlyPrice({ price: 20 }),
items.monthlyMessages({ includedUsage: 600 }),
],
});
// Update to v3
await autumnV1.products.update(pro.id, {
items: [
items.monthlyPrice({ price: 25 }),
items.monthlyMessages({ includedUsage: 700 }),
],
});
// Try to run two migrations concurrently - one should fail
const migration1 = autumnV1.migrate({
from_product_id: pro.id,
to_product_id: pro.id,
from_version: 1,
to_version: 2,
});
const migration2 = autumnV1.migrate({
from_product_id: pro.id,
to_product_id: pro.id,
from_version: 1,
to_version: 2,
});
const results = await Promise.allSettled([migration1, migration2]);
// One should succeed, one should fail with the "migration is ongoing" error
const successes = results.filter((r) => r.status === "fulfilled");
const failures = results.filter((r) => r.status === "rejected");
expect(successes.length).toBe(1);
expect(failures.length).toBe(1);
// The failure should have the "migration is ongoing" message
const failedResult = failures[0] as PromiseRejectedResult;
expect(failedResult.reason.message).toInclude("Another migration is ongoing");
});