Files
cfw-autumn/server/tests/integration/balances/update/usage/update-usage-concurrent.test.ts
2026-02-24 14:45:39 +00:00

418 lines
14 KiB
TypeScript

import { expect, test } from "bun:test";
import type { ApiCustomer, ApiEntityV1 } 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 { timeout } from "@tests/utils/genUtils.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════
// UPDATE-USAGE-CONC1: Update usage concurrent with track on same feature
// Set usage + track events fire together → final state is consistent
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("update-usage-conc1: update usage concurrent with track on same feature")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeProd = products.base({ id: "free", items: [messagesItem] });
const { customerId, autumnV2 } = await initScenario({
customerId: "update-usage-conc1",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
// Fire update usage to 50 concurrently with 3 track events (each +1)
await Promise.allSettled([
autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Messages,
usage: 50,
}),
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
}),
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
}),
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
}),
]);
// Final state depends on ordering: usage and balance should each be 0, 50, or 100
// (set_usage=50 may happen before/after tracks, tracks add 1 each)
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customer.balances[TestFeature.Messages];
expect(balance.granted_balance).toBe(100);
expect([0, 50, 100, 150]).toContain(balance.usage);
expect([0, 50, 100, 150]).toContain(balance.current_balance);
expect(balance.usage + balance.current_balance).toBe(100);
// Verify DB sync
await timeout(2000);
const customerDb = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect([0, 50, 100, 150]).toContain(
customerDb.balances[TestFeature.Messages].usage,
);
expect([0, 50, 100, 150]).toContain(
customerDb.balances[TestFeature.Messages].current_balance,
);
});
// ═══════════════════════════════════════════════════════════════════
// UPDATE-USAGE-CONC2: Update usage and track on different features concurrently
// Messages gets set usage, Credits gets track → both should resolve correctly
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("update-usage-conc2: update usage + track on different features")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const creditsItem = items.monthlyCredits({ includedUsage: 200 });
const freeProd = products.base({
id: "free",
items: [messagesItem, creditsItem],
});
const { customerId, autumnV2 } = await initScenario({
customerId: "update-usage-conc2",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
await autumnV2.customers.get<ApiCustomer>(customerId); // set the customer cache
// Set messages usage to 60, track 4 credits concurrently
const results = await Promise.allSettled([
autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Messages,
usage: 60,
}),
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Credits,
}),
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Credits,
}),
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Credits,
}),
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Credits,
}),
]);
const allFulfilled = results.every((r) => r.status === "fulfilled");
expect(allFulfilled).toBe(true);
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
// Messages: set usage = 60, current = 40
expect(customer.balances[TestFeature.Messages]).toMatchObject({
granted_balance: 100,
current_balance: 40,
usage: 60,
});
// Credits: 4 tracks of 1 each = 4 usage, current = 196
expect(customer.balances[TestFeature.Credits]).toMatchObject({
granted_balance: 200,
current_balance: 196,
usage: 4,
});
// Verify DB sync
await timeout(2000);
const customerDb = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(customerDb.balances[TestFeature.Messages]).toMatchObject({
granted_balance: 100,
current_balance: 40,
usage: 60,
});
expect(customerDb.balances[TestFeature.Credits]).toMatchObject({
granted_balance: 200,
current_balance: 196,
usage: 4,
});
});
// ═══════════════════════════════════════════════════════════════════
// UPDATE-USAGE-CONC3: Paid allocated update usage + track on messages concurrently
// Users (allocated, $10/seat) gets set usage, Messages (metered) gets track
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("update-usage-conc3: paid allocated update usage + track on messages")}`, async () => {
const usersItem = items.allocatedUsers({ includedUsage: 2 });
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const proProd = products.pro({
id: "pro",
items: [usersItem, messagesItem],
});
const { customerId, autumnV2 } = await initScenario({
customerId: "update-usage-conc3",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proProd] }),
],
actions: [s.billing.attach({ productId: proProd.id })],
});
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Users,
usage: 2,
}); // set the balance so that next set usage won't do anything and stripe webhooks won't be sent (so cache isn't cleared)
await timeout(4000);
await autumnV2.customers.get<ApiCustomer>(customerId);
// Set users usage to 5 + track 4 messages concurrently
const results = await Promise.allSettled([
autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Users,
usage: 2,
}),
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
}),
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
}),
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
}),
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
}),
]);
const allFulfilled = results.every((r) => r.status === "fulfilled");
expect(allFulfilled).toBe(true);
await timeout(4000);
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
// Users: 5 usage, 3 purchased (3 over included), current = 0
expect(customer.balances[TestFeature.Users]).toMatchObject({
granted_balance: 2,
current_balance: 0,
purchased_balance: 0,
usage: 2,
});
// Messages: 4 tracks of 1 each = 4 usage, current = 96
expect(customer.balances[TestFeature.Messages]).toMatchObject({
granted_balance: 100,
current_balance: 96,
usage: 4,
});
// Verify invoice: 1 subscription + 1 seat upgrade ($30 for 3 seats)
const customerV3 = await autumnV2.customers.get<ApiCustomer>(customerId);
expect(customerV3.balances[TestFeature.Users]).toMatchObject({
granted_balance: 2,
current_balance: 0,
purchased_balance: 0,
usage: 2,
});
expect(customerV3.balances[TestFeature.Messages]).toMatchObject({
granted_balance: 100,
current_balance: 96,
usage: 4,
});
// Verify DB sync
const customerDb = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(customerDb.balances[TestFeature.Users]).toMatchObject({
granted_balance: 2,
current_balance: 0,
purchased_balance: 0,
usage: 2,
});
expect(customerDb.balances[TestFeature.Messages]).toMatchObject({
granted_balance: 100,
current_balance: 96,
usage: 4,
});
});
// ═══════════════════════════════════════════════════════════════════
// UPDATE-USAGE-CONC4: Update usage on entities concurrent with track
// Different entities get update usage, customer-level gets track
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("update-usage-conc4: entity update usage concurrent with track")}`, async () => {
const messagesItem = items.monthlyMessages({
includedUsage: 100,
entityFeatureId: TestFeature.Users,
});
const creditsItem = items.monthlyCredits({ includedUsage: 200 });
const freeProd = products.base({
id: "free",
items: [messagesItem, creditsItem],
});
const { customerId, autumnV2, entities } = await initScenario({
customerId: "update-usage-conc4",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [s.attach({ productId: freeProd.id })],
});
// Warm caches
await autumnV2.customers.get<ApiCustomer>(customerId);
for (const entity of entities) {
await autumnV2.entities.get<ApiEntityV1>(customerId, entity.id);
}
// Set messages usage on entity1 to 30, entity2 to 50, track 3 credits concurrently
const results = await Promise.allSettled([
autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: entities[0].id,
usage: 30,
}),
autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: entities[1].id,
usage: 50,
}),
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Credits,
}),
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Credits,
}),
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Credits,
}),
]);
const allFulfilled = results.every((r) => r.status === "fulfilled");
expect(allFulfilled).toBe(true);
// Entity1: messages usage=30, current=70
const entity1 = await autumnV2.entities.get<ApiEntityV1>(
customerId,
entities[0].id,
);
expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({
granted_balance: 100,
current_balance: 70,
usage: 30,
});
// Entity2: messages usage=50, current=50
const entity2 = await autumnV2.entities.get<ApiEntityV1>(
customerId,
entities[1].id,
);
expect(entity2.balances?.[TestFeature.Messages]).toMatchObject({
granted_balance: 100,
current_balance: 50,
usage: 50,
});
// Customer: messages aggregate 70+50=120 current, credits 3 tracked
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
expect(customer.balances[TestFeature.Messages]).toMatchObject({
granted_balance: 200,
current_balance: 120,
usage: 80,
});
expect(customer.balances[TestFeature.Credits]).toMatchObject({
granted_balance: 200,
current_balance: 197,
usage: 3,
});
// Verify DB sync
await timeout(2000);
const customerDb = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(customerDb.balances[TestFeature.Messages]).toMatchObject({
granted_balance: 200,
current_balance: 120,
usage: 80,
});
expect(customerDb.balances[TestFeature.Credits]).toMatchObject({
granted_balance: 200,
current_balance: 197,
usage: 3,
});
});
// ═══════════════════════════════════════════════════════════════════
// UPDATE-USAGE-CONC5: Idempotent repeated usage updates
// Setting same usage value multiple times should be idempotent
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("update-usage-conc5: idempotent repeated usage updates")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeProd = products.base({ id: "free", items: [messagesItem] });
const { customerId, autumnV2 } = await initScenario({
customerId: "update-usage-conc5",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
// Set same usage value 5 times rapidly
for (let i = 0; i < 5; i++) {
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Messages,
usage: 42,
});
}
// Should always be the same result: targetBalance = 100 - 42 = 58
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
expect(customer.balances[TestFeature.Messages]).toMatchObject({
granted_balance: 100,
current_balance: 58,
usage: 42,
});
// Verify DB sync
const customerDb = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(customerDb.balances[TestFeature.Messages]).toMatchObject({
granted_balance: 100,
current_balance: 58,
usage: 42,
});
});