cleaned up usage windows
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { ApiVersion, ErrCode } from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.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";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import {
|
||||
expectCustomerUsageLimit,
|
||||
setCustomerUsageLimit,
|
||||
} from "../../utils/usage-limit-utils/customerUsageLimitUtils.js";
|
||||
import {
|
||||
expectEntityUsageLimit,
|
||||
setEntityUsageLimit,
|
||||
} from "../../utils/usage-limit-utils/entityUsageLimitUtils.js";
|
||||
import { fetchUsageWindowRows } from "../../utils/usage-limit-utils/usageWindowDbTestUtils.js";
|
||||
|
||||
/**
|
||||
* TDD tests for usage-limit INHERITANCE (spend-limit mirror: per feature the
|
||||
* entity's own usage_limits entry wins; without one the customer's entry
|
||||
* "fills the gap" and applies at CUSTOMER scope — one shared aggregate window
|
||||
* across the customer and every entity without its own cap).
|
||||
*
|
||||
* Contract under test (inheritance half):
|
||||
* - no entity entry -> entity tracks count into the shared customer window
|
||||
* (inherit1 pins this aggregate behavior)
|
||||
* - entity with its own entry is CARVED OUT: its tracks consume only its
|
||||
* entity window, never the customer's aggregate window (inherit2)
|
||||
* - arming an entity cap mid-window moves that entity to a fresh entity
|
||||
* window; the customer window keeps its count (inherit3)
|
||||
*
|
||||
* Pre-impl red: inherit2/inherit3 fail because entity usage_limits don't
|
||||
* exist, so every entity track still lands in the customer window.
|
||||
*/
|
||||
|
||||
const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 });
|
||||
|
||||
test.concurrent(
|
||||
`${chalk.yellowBright("ent-uw-inherit1: entities without their own cap share the customer's aggregate window")}`,
|
||||
async () => {
|
||||
const perEntityProduct = products.base({
|
||||
id: "ent-uw-inherit-aggregate",
|
||||
items: [
|
||||
items.monthlyMessages({
|
||||
includedUsage: 100,
|
||||
entityFeatureId: TestFeature.Users,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const customerId = "ent-uw-inherit-1";
|
||||
const { entities, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false }),
|
||||
s.products({ list: [perEntityProduct] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: perEntityProduct.id })],
|
||||
});
|
||||
|
||||
await setCustomerUsageLimit({
|
||||
autumn: autumnV2_3,
|
||||
customerId,
|
||||
featureId: TestFeature.Messages,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
await autumnV2_3.customers.get(customerId); // initialize cache.
|
||||
for (const entity of entities) {
|
||||
await autumnV2_3.entities.get(customerId, entity.id); // initialize cache.
|
||||
}
|
||||
|
||||
// e0: 3, e1: 1 -> aggregate 4. e1's next 3 applies only the remaining 1.
|
||||
await autumnV2_3.track({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 3,
|
||||
});
|
||||
await autumnV2_3.track({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[1].id,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 1,
|
||||
});
|
||||
await autumnV2_3.track({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[1].id,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 3,
|
||||
});
|
||||
|
||||
await expectCustomerUsageLimit({
|
||||
autumn: autumnV2_3,
|
||||
customerId,
|
||||
featureId: TestFeature.Messages,
|
||||
usage: 5,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
await expectAutumnError({
|
||||
errCode: ErrCode.InsufficientBalance,
|
||||
func: async () =>
|
||||
await autumnV2_3.track({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 1,
|
||||
overage_behavior: "reject",
|
||||
}),
|
||||
});
|
||||
|
||||
// ── Side effect: ONE shared customer-scope row, no entity rows ──
|
||||
await timeout(4000);
|
||||
const rows = await fetchUsageWindowRows({
|
||||
ctx,
|
||||
customerId,
|
||||
featureId: TestFeature.Messages,
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].internal_entity_id).toBeNull();
|
||||
expect(Number(rows[0].usage)).toBe(5);
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent(
|
||||
`${chalk.yellowBright("ent-uw-inherit2: an entity with its own cap is carved out of the customer's aggregate window")}`,
|
||||
async () => {
|
||||
const perEntityProduct = products.base({
|
||||
id: "ent-uw-inherit-carveout",
|
||||
items: [
|
||||
items.monthlyMessages({
|
||||
includedUsage: 100,
|
||||
entityFeatureId: TestFeature.Users,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const customerId = "ent-uw-inherit-2";
|
||||
const { entities, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false }),
|
||||
s.products({ list: [perEntityProduct] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: perEntityProduct.id })],
|
||||
});
|
||||
|
||||
await setCustomerUsageLimit({
|
||||
autumn: autumnV2_3,
|
||||
customerId,
|
||||
featureId: TestFeature.Messages,
|
||||
limit: 5,
|
||||
});
|
||||
await setEntityUsageLimit({
|
||||
autumn: autumnV2_3,
|
||||
customerId,
|
||||
entityId: entities[1].id,
|
||||
featureId: TestFeature.Messages,
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
await autumnV2_3.customers.get(customerId); // initialize cache.
|
||||
for (const entity of entities) {
|
||||
await autumnV2_3.entities.get(customerId, entity.id); // initialize cache.
|
||||
}
|
||||
|
||||
// e1 (own cap 2) tracks 3 -> applies 2, into its OWN window.
|
||||
await autumnV2_3.track({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[1].id,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 3,
|
||||
});
|
||||
await expectEntityUsageLimit({
|
||||
autumn: autumnV2_3,
|
||||
customerId,
|
||||
entityId: entities[1].id,
|
||||
featureId: TestFeature.Messages,
|
||||
usage: 2,
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
// e0 (no own cap) tracks 5 -> the FULL 5 fits in the customer window,
|
||||
// proving e1's tracks never touched it (else only 3 would fit).
|
||||
await autumnV2_3.track({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 5,
|
||||
});
|
||||
await expectCustomerUsageLimit({
|
||||
autumn: autumnV2_3,
|
||||
customerId,
|
||||
featureId: TestFeature.Messages,
|
||||
usage: 5,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
// Both windows now full: e0 rejects on the customer window, e1 on its own.
|
||||
await expectAutumnError({
|
||||
errCode: ErrCode.InsufficientBalance,
|
||||
func: async () =>
|
||||
await autumnV2_3.track({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 1,
|
||||
overage_behavior: "reject",
|
||||
}),
|
||||
});
|
||||
await expectAutumnError({
|
||||
errCode: ErrCode.InsufficientBalance,
|
||||
func: async () =>
|
||||
await autumnV2_3.track({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[1].id,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 1,
|
||||
overage_behavior: "reject",
|
||||
}),
|
||||
});
|
||||
|
||||
// ── Side effect: one customer-scope row (5) + one entity row (2) ──
|
||||
await timeout(4000);
|
||||
const rows = await fetchUsageWindowRows({
|
||||
ctx,
|
||||
customerId,
|
||||
featureId: TestFeature.Messages,
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
const customerRow = rows.find(
|
||||
(row: { internal_entity_id: string | null }) =>
|
||||
row.internal_entity_id === null,
|
||||
);
|
||||
const entityRow = rows.find(
|
||||
(row: { internal_entity_id: string | null }) =>
|
||||
row.internal_entity_id !== null,
|
||||
);
|
||||
expect(customerRow).toBeDefined();
|
||||
expect(entityRow).toBeDefined();
|
||||
expect(Number(customerRow.usage)).toBe(5);
|
||||
expect(Number(entityRow.usage)).toBe(2);
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent(
|
||||
`${chalk.yellowBright("ent-uw-inherit3: arming an entity cap mid-window moves the entity to a fresh window")}`,
|
||||
async () => {
|
||||
const perEntityProduct = products.base({
|
||||
id: "ent-uw-inherit-midwindow",
|
||||
items: [
|
||||
items.monthlyMessages({
|
||||
includedUsage: 100,
|
||||
entityFeatureId: TestFeature.Users,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const customerId = "ent-uw-inherit-3";
|
||||
const { entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false }),
|
||||
s.products({ list: [perEntityProduct] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: perEntityProduct.id })],
|
||||
});
|
||||
|
||||
await setCustomerUsageLimit({
|
||||
autumn: autumnV2_3,
|
||||
customerId,
|
||||
featureId: TestFeature.Messages,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
await autumnV2_3.customers.get(customerId); // initialize cache.
|
||||
for (const entity of entities) {
|
||||
await autumnV2_3.entities.get(customerId, entity.id); // initialize cache.
|
||||
}
|
||||
|
||||
// e0 inherits: 4 land in the customer window.
|
||||
await autumnV2_3.track({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 4,
|
||||
});
|
||||
await expectCustomerUsageLimit({
|
||||
autumn: autumnV2_3,
|
||||
customerId,
|
||||
featureId: TestFeature.Messages,
|
||||
usage: 4,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
// Carve e0 out mid-window: its next 5 fit its FRESH entity window
|
||||
// (had it still tracked the customer window, only 1 would fit).
|
||||
await setEntityUsageLimit({
|
||||
autumn: autumnV2_3,
|
||||
customerId,
|
||||
entityId: entities[0].id,
|
||||
featureId: TestFeature.Messages,
|
||||
limit: 5,
|
||||
});
|
||||
await autumnV2_3.track({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 5,
|
||||
});
|
||||
await expectEntityUsageLimit({
|
||||
autumn: autumnV2_3,
|
||||
customerId,
|
||||
entityId: entities[0].id,
|
||||
featureId: TestFeature.Messages,
|
||||
usage: 5,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
// Customer window kept its 4: e1 (still inheriting) fits exactly 1 more.
|
||||
await autumnV2_3.track({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[1].id,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 3,
|
||||
});
|
||||
await expectCustomerUsageLimit({
|
||||
autumn: autumnV2_3,
|
||||
customerId,
|
||||
featureId: TestFeature.Messages,
|
||||
usage: 5,
|
||||
limit: 5,
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user