Files
cfw-autumn/server/tests/integration/balances/reset/get-customer-reset-rollovers-concurrent.test.ts
2026-02-24 16:14:05 +00:00

293 lines
11 KiB
TypeScript

import { expect, test } from "bun:test";
import {
type ApiCustomer,
type CheckResponseV2,
RolloverExpiryDurationType,
} from "@autumn/shared";
import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expireCusEntForReset } from "@tests/utils/cusProductUtils/resetTestUtils.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";
const rolloverConfig = {
max: 500,
length: 1,
duration: RolloverExpiryDurationType.Month,
};
// ─────────────────────────────────────────────────────────────────
// Concurrent GET /customers with rollover — reset exactly once
// ─────────────────────────────────────────────────────────────────
test.concurrent(`${chalk.yellowBright("concurrent rollover reset: multiple GETs all return reset balance with rollover")}`, async () => {
const messagesItem = items.monthlyMessagesWithRollover({
includedUsage: 400,
rolloverConfig,
});
const pro = products.pro({ items: [messagesItem] });
const { customerId, autumnV2, ctx } = await initScenario({
customerId: "reset-rollover-conc-get",
setup: [
s.customer({ paymentMethod: "success", testClock: false }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: pro.id }),
s.track({ featureId: TestFeature.Messages, value: 250, timeout: 2000 }),
],
});
// Before reset: 400 - 250 = 150 remaining
const before = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(before.balances[TestFeature.Messages].current_balance).toBe(150);
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
// Fire 5 concurrent GET requests — all should see reset balance with rollover
// Expected: rollover = min(150, 500) = 150, fresh grant = 400, total = 550
const results = await Promise.all(
Array.from({ length: 5 }, () =>
autumnV2.customers.get<ApiCustomer>(customerId),
),
);
for (const customer of results) {
expect(customer.balances[TestFeature.Messages].current_balance).toBe(550);
expect(customer.balances[TestFeature.Messages].usage).toBe(0);
expect(customer.balances[TestFeature.Messages].rollovers).toBeDefined();
expect(customer.balances[TestFeature.Messages].rollovers!.length).toBe(1);
expect(customer.balances[TestFeature.Messages].rollovers![0].balance).toBe(
150,
);
}
// DB should also reflect the reset (only applied once)
const cusEntAfter = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEntAfter).toBeDefined();
expect(cusEntAfter!.next_reset_at).toBeGreaterThan(Date.now());
expect(cusEntAfter!.rollovers.length).toBe(1);
});
// ─────────────────────────────────────────────────────────────────
// Concurrent checks with rollover — all return reset balance
// ─────────────────────────────────────────────────────────────────
test.concurrent(`${chalk.yellowBright("concurrent rollover reset: multiple checks all return reset balance with rollover")}`, async () => {
const messagesItem = items.monthlyMessagesWithRollover({
includedUsage: 400,
rolloverConfig,
});
const pro = products.pro({ items: [messagesItem] });
const { customerId, autumnV2, ctx } = await initScenario({
customerId: "reset-rollover-conc-check",
setup: [
s.customer({ paymentMethod: "success", testClock: false }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: pro.id }),
s.track({ featureId: TestFeature.Messages, value: 100, timeout: 2000 }),
],
});
// Before reset: 400 - 100 = 300 remaining
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
// Fire 5 concurrent check requests
// Expected: rollover = min(300, 500) = 300, fresh grant = 400, total = 700
const results = await Promise.all(
Array.from({ length: 5 }, () =>
autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
}),
),
);
for (const res of results) {
const check = res as unknown as CheckResponseV2;
expect(check.allowed).toBe(true);
expect(check.balance?.current_balance).toBe(700);
expect(check.balance?.usage).toBe(0);
}
});
// ─────────────────────────────────────────────────────────────────
// Concurrent tracks with rollover — reset once, deductions atomic
// ─────────────────────────────────────────────────────────────────
test.concurrent(`${chalk.yellowBright("concurrent rollover reset: multiple tracks reset once then deduct atomically")}`, async () => {
const messagesItem = items.monthlyMessagesWithRollover({
includedUsage: 400,
rolloverConfig,
});
const pro = products.pro({ items: [messagesItem] });
const { customerId, autumnV2, ctx } = await initScenario({
customerId: "reset-rollover-conc-track",
setup: [
s.customer({ paymentMethod: "success", testClock: false }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: pro.id }),
s.track({ featureId: TestFeature.Messages, value: 200, timeout: 2000 }),
],
});
// Before reset: 400 - 200 = 200 remaining
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
// Fire 5 concurrent tracks of 10 each
// Expected: rollover = min(200, 500) = 200, fresh grant = 400, total = 600
// Then deduct 50 total → 550
await Promise.all(
Array.from({ length: 5 }, () =>
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 10,
}),
),
);
// Verify final balance: 600 (reset) - 50 (5 * 10) = 550
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
expect(customer.balances[TestFeature.Messages].current_balance).toBe(550);
expect(customer.balances[TestFeature.Messages].usage).toBe(50);
expect(customer.balances[TestFeature.Messages].rollovers).toBeDefined();
expect(customer.balances[TestFeature.Messages].rollovers!.length).toBe(1);
// Wait for DB sync and verify DB agrees
await new Promise((resolve) => setTimeout(resolve, 2000));
const customerDb = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(customerDb.balances[TestFeature.Messages].current_balance).toBe(550);
expect(customerDb.balances[TestFeature.Messages].usage).toBe(50);
const cusEntAfter = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEntAfter).toBeDefined();
expect(cusEntAfter!.next_reset_at).toBeGreaterThan(Date.now());
// Only 1 rollover should exist (not duplicated by concurrent requests)
expect(cusEntAfter!.rollovers.length).toBe(1);
});
// ─────────────────────────────────────────────────────────────────
// Concurrent GETs after multiple resets — excess rollovers cleared from cache
// ─────────────────────────────────────────────────────────────────
test.concurrent(`${chalk.yellowBright("concurrent rollover reset: excess rollovers cleared from cache after max cap")}`, async () => {
const maxRolloverConfig = {
max: 80,
length: 1,
duration: RolloverExpiryDurationType.Month,
};
const messagesItem = items.monthlyMessagesWithRollover({
includedUsage: 100,
rolloverConfig: maxRolloverConfig,
});
const pro = products.pro({ items: [messagesItem] });
const { customerId, autumnV2, ctx } = await initScenario({
customerId: "reset-rollover-conc-max-clear",
setup: [
s.customer({ paymentMethod: "success", testClock: false }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: pro.id }),
s.track({ featureId: TestFeature.Messages, value: 50, timeout: 2000 }),
],
});
// Before first reset: 100 - 50 = 50 remaining
const before = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(before.balances[TestFeature.Messages].current_balance).toBe(50);
// --- First reset: rollover(50), fresh grant 100, total = 150 ---
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
const afterReset1 = await autumnV2.customers.get<ApiCustomer>(customerId);
expect(afterReset1.balances[TestFeature.Messages].current_balance).toBe(150);
expect(afterReset1.balances[TestFeature.Messages].rollovers!.length).toBe(1);
expect(afterReset1.balances[TestFeature.Messages].rollovers![0].balance).toBe(
50,
);
// --- Second reset: balance is 100 (not tracked), creates rollover(100) ---
// Total rollovers: [old(50), new(100)] = 150 > max(80)
// After clearing: old deleted, new trimmed to 80 → 1 rollover with 80
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
// Fire 5 concurrent GETs to trigger the second reset.
// The winner applies clearing (delete old rollover, trim new to 80).
// Losers may read DB before clearing finishes, so concurrent results
// can transiently show uncapped rollovers. That's expected.
await Promise.all(
Array.from({ length: 5 }, () =>
autumnV2.customers.get<ApiCustomer>(customerId),
),
);
// After all concurrent requests settle, cache and DB should be consistent.
// A fresh GET should return the max-cleared state:
// 1 rollover with balance=80, fresh grant=100, total=180
const afterSettle = await autumnV2.customers.get<ApiCustomer>(customerId);
const msgBalance = afterSettle.balances[TestFeature.Messages];
expect(msgBalance.current_balance).toBe(180);
expect(msgBalance.usage).toBe(0);
expect(msgBalance.rollovers).toBeDefined();
expect(msgBalance.rollovers!.length).toBe(1);
expect(msgBalance.rollovers![0].balance).toBe(80);
// DB should also reflect the cleared state
const cusEntAfter = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEntAfter).toBeDefined();
expect(cusEntAfter!.next_reset_at).toBeGreaterThan(Date.now());
expect(cusEntAfter!.rollovers.length).toBe(1);
expect(cusEntAfter!.rollovers[0].balance).toBe(80);
});