Files
cfw-autumn/server/tests/integration/billing/update-subscription/update-quantity/multi-feature-quantity.test.ts
2026-01-16 14:37:41 +00:00

258 lines
7.5 KiB
TypeScript

import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
/**
* Subscription Update - Feature Matching Tests
*
* These tests verify that the subscription update flow correctly matches
* features by feature_id rather than array index. This prevents critical
* bugs where reordered features or partial updates could cause incorrect
* billing calculations.
*
* Critical bug fix: Previously used array index to match old vs new options,
* which would break if features were sent in a different order.
*/
test.concurrent(`${chalk.yellowBright("update-quantity: features in reverse order")}`, async () => {
const customerId = "multi-feat-reverse-order";
const product = products.base({
id: "multi_feature",
items: [
items.prepaid({
featureId: TestFeature.Messages,
billingUnits: 10,
price: 5,
}),
items.prepaid({
featureId: TestFeature.Words,
billingUnits: 100,
}),
items.prepaid({
featureId: TestFeature.Users,
billingUnits: 1,
price: 2,
}),
],
});
// Attach with features in order: Messages, Words, Users
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [product] }),
],
actions: [
s.attach({
productId: product.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 10 * 10 }, // 100 messages
{ feature_id: TestFeature.Words, quantity: 5 * 100 }, // 500 words
{ feature_id: TestFeature.Users, quantity: 20 * 1 }, // 20 users
],
}),
],
});
// Update with features in REVERSE order: Users, Words, Messages
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: product.id,
options: [
{ feature_id: TestFeature.Users, quantity: 50 * 1 }, // 50 users (increased)
{ feature_id: TestFeature.Words, quantity: 10 * 100 }, // 1000 words (increased)
{ feature_id: TestFeature.Messages, quantity: 5 * 10 }, // 50 messages (decreased)
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Each feature should be updated correctly despite reordering
expect(customer.features?.[TestFeature.Messages]?.balance).toBe(50);
expect(customer.features?.[TestFeature.Words]?.balance).toBe(1000);
expect(customer.features?.[TestFeature.Users]?.balance).toBe(50);
});
test.concurrent(`${chalk.yellowBright("update-quantity: features in random order")}`, async () => {
const customerId = "multi-feat-random-order";
const product = products.base({
id: "multi_feature",
items: [
items.prepaid({
featureId: TestFeature.Messages,
billingUnits: 10,
price: 5,
}),
items.prepaid({
featureId: TestFeature.Words,
billingUnits: 100,
}),
items.prepaid({
featureId: TestFeature.Users,
billingUnits: 1,
price: 2,
}),
],
});
// Attach with features in order: Messages, Words, Users
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [product] }),
],
actions: [
s.attach({
productId: product.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 10 * 10 },
{ feature_id: TestFeature.Words, quantity: 5 * 100 },
{ feature_id: TestFeature.Users, quantity: 20 * 1 },
],
}),
],
});
// Update with features in random order: Words, Users, Messages
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: product.id,
options: [
{ feature_id: TestFeature.Words, quantity: 20 * 100 }, // 2000 words
{ feature_id: TestFeature.Users, quantity: 100 * 1 }, // 100 users
{ feature_id: TestFeature.Messages, quantity: 15 * 10 }, // 150 messages
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.features?.[TestFeature.Messages]?.balance).toBe(150);
expect(customer.features?.[TestFeature.Words]?.balance).toBe(2000);
expect(customer.features?.[TestFeature.Users]?.balance).toBe(100);
});
test.concurrent(`${chalk.yellowBright("update-quantity: partial update (one feature changed)")}`, async () => {
const customerId = "multi-feat-partial-update";
const product = products.base({
id: "multi_feature",
items: [
items.prepaid({
featureId: TestFeature.Messages,
billingUnits: 10,
price: 5,
}),
items.prepaid({
featureId: TestFeature.Words,
billingUnits: 100,
}),
items.prepaid({
featureId: TestFeature.Users,
billingUnits: 1,
price: 2,
}),
],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [product] }),
],
actions: [
s.attach({
productId: product.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 15 * 10 }, // 150 messages
{ feature_id: TestFeature.Words, quantity: 20 * 100 }, // 2000 words
{ feature_id: TestFeature.Users, quantity: 100 * 1 }, // 100 users
],
}),
],
});
// Only update Words, keep others the same
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: product.id,
options: [
{ feature_id: TestFeature.Words, quantity: 30 * 100 }, // 3000 words - only updating this
{ feature_id: TestFeature.Messages, quantity: 15 * 10 }, // Keep at 150
{ feature_id: TestFeature.Users, quantity: 100 * 1 }, // Keep at 100
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Words should be updated
expect(customer.features?.[TestFeature.Words]?.balance).toBe(3000);
// Others should remain the same
expect(customer.features?.[TestFeature.Messages]?.balance).toBe(150);
expect(customer.features?.[TestFeature.Users]?.balance).toBe(100);
});
test.concurrent(`${chalk.yellowBright("update-quantity: duplicate feature_ids uses last value")}`, async () => {
const customerId = "multi-feat-duplicate-ids";
const product = products.base({
id: "two_feature",
items: [
items.prepaid({
featureId: TestFeature.Messages,
billingUnits: 10,
price: 5,
}),
items.prepaid({
featureId: TestFeature.Words,
billingUnits: 100,
}),
],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [product] }),
],
actions: [
s.attach({
productId: product.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 10 * 10 },
{ feature_id: TestFeature.Words, quantity: 5 * 100 },
],
}),
],
});
// Send the same feature twice (edge case - should use last value or error)
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: product.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 20 * 10 }, // First value: 200
{ feature_id: TestFeature.Messages, quantity: 30 * 10 }, // Second value (duplicate): 300
{ feature_id: TestFeature.Words, quantity: 10 * 100 },
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// The last value should win (or this should error - either is acceptable)
const messagesBalance = customer.features?.[TestFeature.Messages]?.balance;
// Should be either 200 (first) or 300 (second), not some weird value from index mismatch
expect([200, 300]).toContain(messagesBalance ?? 0);
});