355 lines
9.5 KiB
TypeScript
355 lines
9.5 KiB
TypeScript
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 { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
|
import chalk from "chalk";
|
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
|
import {
|
|
expectCustomerFeatureBalance,
|
|
expectEntityFeatureBalance,
|
|
} from "../../utils/spend-limit-utils/entitySpendLimitUtils.js";
|
|
import {
|
|
expectEntityUsageLimit,
|
|
setEntityUsageLimit,
|
|
} from "../../utils/usage-limit-utils/entityUsageLimitUtils.js";
|
|
import { fetchUsageWindowRows } from "../../utils/usage-limit-utils/usageWindowDbTestUtils.js";
|
|
|
|
/**
|
|
* TDD tests for ENTITY-LEVEL usage windows (spend-limit mirror semantics:
|
|
* exactly ONE cap per feature per subject — the entity's own usage_limits
|
|
* entry wins; without one the customer's entry applies at customer scope).
|
|
*
|
|
* Contract under test (enforcement half):
|
|
* - entities.update(..., { billing_controls: { usage_limits } }) arms a cap
|
|
* - entity tracks against an ENTITY-SCOPED window: counts only that entity's
|
|
* usage, clamps over-cap tracks to what fits ("apply what fits")
|
|
* - overage_behavior "reject" over the cap -> InsufficientBalance
|
|
* - windows are isolated between entities; customer balance still aggregates
|
|
* - entity cap works on customer-scoped features too (no entity_feature_id):
|
|
* only that entity's tracks count; customer-level tracks are uncapped
|
|
* - side effect: usage_windows PG row per entity with internal_entity_id set
|
|
*
|
|
* Pre-impl red: EntityBillingControls has no usage_limits, so the update is
|
|
* rejected/dropped and no cap is ever enforced.
|
|
*/
|
|
|
|
const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 });
|
|
|
|
test.concurrent(
|
|
`${chalk.yellowBright("ent-uw-enforce1: entity's own cap clamps that entity's tracks")}`,
|
|
async () => {
|
|
const perEntityProduct = products.base({
|
|
id: "ent-uw-enforce-own-cap",
|
|
items: [
|
|
items.monthlyMessages({
|
|
includedUsage: 100,
|
|
entityFeatureId: TestFeature.Users,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const customerId = "ent-uw-enforce-1";
|
|
const { entities, ctx } = await initScenario({
|
|
customerId,
|
|
setup: [
|
|
s.customer({ testClock: false }),
|
|
s.products({ list: [perEntityProduct] }),
|
|
s.entities({ count: 1, featureId: TestFeature.Users }),
|
|
],
|
|
actions: [s.billing.attach({ productId: perEntityProduct.id })],
|
|
});
|
|
|
|
await setEntityUsageLimit({
|
|
autumn: autumnV2_3,
|
|
customerId,
|
|
entityId: entities[0].id,
|
|
featureId: TestFeature.Messages,
|
|
limit: 5,
|
|
});
|
|
|
|
// ── Cap reached exactly: 3 then 4 applies only the remaining 2 ──
|
|
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[0].id,
|
|
feature_id: TestFeature.Messages,
|
|
value: 4,
|
|
});
|
|
|
|
await expectEntityFeatureBalance({
|
|
autumn: autumnV2_3,
|
|
customerId,
|
|
entityId: entities[0].id,
|
|
featureId: TestFeature.Messages,
|
|
granted: 100,
|
|
remaining: 95,
|
|
usage: 5,
|
|
});
|
|
await expectEntityUsageLimit({
|
|
autumn: autumnV2_3,
|
|
customerId,
|
|
entityId: entities[0].id,
|
|
featureId: TestFeature.Messages,
|
|
usage: 5,
|
|
limit: 5,
|
|
});
|
|
|
|
// ── Over the cap: a further track applies nothing ──
|
|
await autumnV2_3.track({
|
|
customer_id: customerId,
|
|
entity_id: entities[0].id,
|
|
feature_id: TestFeature.Messages,
|
|
value: 1,
|
|
});
|
|
await expectEntityFeatureBalance({
|
|
autumn: autumnV2_3,
|
|
customerId,
|
|
entityId: entities[0].id,
|
|
featureId: TestFeature.Messages,
|
|
granted: 100,
|
|
remaining: 95,
|
|
usage: 5,
|
|
});
|
|
|
|
// ── Side effect: ONE window row, entity-scoped (no customer row) ──
|
|
const rows = await fetchUsageWindowRows({
|
|
ctx,
|
|
customerId,
|
|
featureId: TestFeature.Messages,
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].internal_entity_id).not.toBeNull();
|
|
expect(Number(rows[0].usage)).toBe(5);
|
|
},
|
|
);
|
|
|
|
test.concurrent(
|
|
`${chalk.yellowBright("ent-uw-enforce2: two entities with different caps stay isolated while customer balance aggregates")}`,
|
|
async () => {
|
|
const perEntityProduct = products.base({
|
|
id: "ent-uw-enforce-isolated",
|
|
items: [
|
|
items.monthlyMessages({
|
|
includedUsage: 100,
|
|
entityFeatureId: TestFeature.Users,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const customerId = "ent-uw-enforce-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 setEntityUsageLimit({
|
|
autumn: autumnV2_3,
|
|
customerId,
|
|
entityId: entities[0].id,
|
|
featureId: TestFeature.Messages,
|
|
limit: 5,
|
|
});
|
|
await setEntityUsageLimit({
|
|
autumn: autumnV2_3,
|
|
customerId,
|
|
entityId: entities[1].id,
|
|
featureId: TestFeature.Messages,
|
|
limit: 10,
|
|
});
|
|
|
|
// e0: 7 -> clamps to 5. e1: 7 then 5 -> clamps to 10 total.
|
|
await autumnV2_3.track({
|
|
customer_id: customerId,
|
|
entity_id: entities[0].id,
|
|
feature_id: TestFeature.Messages,
|
|
value: 7,
|
|
});
|
|
await autumnV2_3.track({
|
|
customer_id: customerId,
|
|
entity_id: entities[1].id,
|
|
feature_id: TestFeature.Messages,
|
|
value: 7,
|
|
});
|
|
await autumnV2_3.track({
|
|
customer_id: customerId,
|
|
entity_id: entities[1].id,
|
|
feature_id: TestFeature.Messages,
|
|
value: 5,
|
|
});
|
|
|
|
await expectEntityUsageLimit({
|
|
autumn: autumnV2_3,
|
|
customerId,
|
|
entityId: entities[0].id,
|
|
featureId: TestFeature.Messages,
|
|
usage: 5,
|
|
limit: 5,
|
|
});
|
|
await expectEntityUsageLimit({
|
|
autumn: autumnV2_3,
|
|
customerId,
|
|
entityId: entities[1].id,
|
|
featureId: TestFeature.Messages,
|
|
usage: 10,
|
|
limit: 10,
|
|
});
|
|
|
|
// Balances aggregate at the customer even though windows are isolated.
|
|
await expectCustomerFeatureBalance({
|
|
autumn: autumnV2_3,
|
|
customerId,
|
|
featureId: TestFeature.Messages,
|
|
granted: 200,
|
|
remaining: 185,
|
|
usage: 15,
|
|
});
|
|
|
|
// ── Side effect: one row per entity, distinct scopes ──
|
|
const rows = await fetchUsageWindowRows({
|
|
ctx,
|
|
customerId,
|
|
featureId: TestFeature.Messages,
|
|
});
|
|
expect(rows).toHaveLength(2);
|
|
const entityIds = rows.map(
|
|
(row: { internal_entity_id: string | null }) => row.internal_entity_id,
|
|
);
|
|
expect(entityIds[0]).not.toBeNull();
|
|
expect(entityIds[1]).not.toBeNull();
|
|
expect(entityIds[0]).not.toBe(entityIds[1]);
|
|
expect(
|
|
rows
|
|
.map((row: { usage: string | number }) => Number(row.usage))
|
|
.sort((a: number, b: number) => a - b),
|
|
).toEqual([5, 10]);
|
|
},
|
|
);
|
|
|
|
test.concurrent(
|
|
`${chalk.yellowBright("ent-uw-enforce3: over-cap entity track with reject returns InsufficientBalance")}`,
|
|
async () => {
|
|
const perEntityProduct = products.base({
|
|
id: "ent-uw-enforce-reject",
|
|
items: [
|
|
items.monthlyMessages({
|
|
includedUsage: 100,
|
|
entityFeatureId: TestFeature.Users,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const customerId = "ent-uw-enforce-3";
|
|
const { entities } = await initScenario({
|
|
customerId,
|
|
setup: [
|
|
s.customer({ testClock: false }),
|
|
s.products({ list: [perEntityProduct] }),
|
|
s.entities({ count: 1, featureId: TestFeature.Users }),
|
|
],
|
|
actions: [s.billing.attach({ productId: perEntityProduct.id })],
|
|
});
|
|
|
|
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 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",
|
|
}),
|
|
});
|
|
},
|
|
);
|
|
|
|
test.concurrent(
|
|
`${chalk.yellowBright("ent-uw-enforce4: entity cap on a customer-scoped feature counts only that entity's tracks")}`,
|
|
async () => {
|
|
const customerProduct = products.base({
|
|
id: "ent-uw-enforce-cus-feature",
|
|
items: [items.monthlyMessages({ includedUsage: 100 })],
|
|
});
|
|
|
|
const customerId = "ent-uw-enforce-4";
|
|
const { entities } = await initScenario({
|
|
customerId,
|
|
setup: [
|
|
s.customer({ testClock: false }),
|
|
s.products({ list: [customerProduct] }),
|
|
s.entities({ count: 1, featureId: TestFeature.Users }),
|
|
],
|
|
actions: [s.billing.attach({ productId: customerProduct.id })],
|
|
});
|
|
|
|
await autumnV2_3.customers.get(customerId); // initialize cache.
|
|
|
|
await setEntityUsageLimit({
|
|
autumn: autumnV2_3,
|
|
customerId,
|
|
entityId: entities[0].id,
|
|
featureId: TestFeature.Messages,
|
|
limit: 3,
|
|
});
|
|
|
|
// Entity track over its cap: applies 3 of 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: 3,
|
|
limit: 3,
|
|
});
|
|
|
|
// Customer-level track is NOT bound by the entity's cap.
|
|
await autumnV2_3.track({
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: 10,
|
|
});
|
|
await expectCustomerFeatureBalance({
|
|
autumn: autumnV2_3,
|
|
customerId,
|
|
featureId: TestFeature.Messages,
|
|
granted: 100,
|
|
remaining: 87,
|
|
usage: 13,
|
|
});
|
|
},
|
|
);
|