17 KiB
17 KiB
Guide: Writing /track Endpoint Tests
What is /track?
The /track endpoint records usage for metered features and deducts from customer balances.
Parameters:
customer_id(required) - The customer to track usage forfeature_idORevent_name(required) - The feature or event to trackvalue(optional) - The amount to track (defaults to 1)entity_id(optional) - For entity-scoped features
Behavior:
- Deducts from customer balances
- Returns synchronously (no need for timeouts)
- Supports credit systems with automatic fallback
- Handles concurrent requests with SQL-level atomicity
Step-by-Step: Writing a /track Test
Step 1: Define What You're Testing
Identify the specific scenario:
- Basic metered feature deduction
- Credit system deduction
- Event-based tracking (multiple features from one event)
- Deduction order (feature → credit system)
- Concurrent track requests
- Balance capping (stop at 0 vs allow negative)
- Entity-scoped tracking
Step 2: Construct Features & Products
Feature Types
Basic Metered Features:
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
});
Event-Based Features (multiple features triggered by one event):
// Both action1 and action2 listen to "action-event"
const action1Feature = constructFeatureItem({
featureId: TestFeature.Action1,
includedUsage: 200,
});
const action2Feature = constructFeatureItem({
featureId: TestFeature.Action2,
includedUsage: 150,
});
Credit Systems (fallback pool for actions):
const creditsFeature = constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage: 100,
}) as LimitedItem;
// Action1 consumes from Credits with credit_cost = 0.2
// Action2 consumes from Credits with credit_cost = 0.6
Combine into Products
const freeProd = constructProduct({
type: "free", // IMPORTANT: Set type to "free" for immediate attachment
isDefault: false,
items: [messagesFeature, creditsFeature],
});
IMPORTANT: Product Type
type: "free"- Feature is attached to customer immediately afterattach()calltype: "pro"or other paid types - Feature requires payment/subscription flow and may not be immediately available for testing- Rule of thumb: For track/check tests, always use
type: "free"unless specifically testing paid subscription flows
Step 3: Initialize Test Environment
Always use this exact order in beforeAll:
import { Decimal } from "decimal.js";
const testCase = "track-basic1";
const customerId = "track-basic1";
beforeAll(async () => {
// 1. Create customer
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
// 2. Create products
await initProductsV0({
ctx,
products: [freeProd],
prefix: testCase,
});
// 3. Attach product to customer
await autumnV1.attach({
customer_id: customerId,
product_id: freeProd.id,
});
});
Step 4: Write Test Cases
IMPORTANT: Use Decimal for balance calculations to avoid floating point errors
test("should deduct exact value provided", async () => {
const initialBalance = 100;
const deductValue = 23.47;
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const usage = customer.features[TestFeature.Messages].usage;
// Use Decimal to avoid floating point errors
const expectedBalance = new Decimal(initialBalance).sub(deductValue).toNumber();
expect(balance).toBe(expectedBalance);
expect(usage).toBe(deductValue);
});
Common Scenarios
1. Basic Track (No Value)
test("should deduct 1 when no value provided", async () => {
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
// No value = defaults to 1
});
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Messages].balance).toBe(99);
expect(customer.features[TestFeature.Messages].usage).toBe(1);
});
2. Track with Value
test("should deduct exact value", async () => {
const initialBalance = 100;
const deductValue = 37.89; // Use decimals for robustness
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
const expectedBalance = new Decimal(initialBalance).sub(deductValue).toNumber();
expect(customer.features[TestFeature.Messages].balance).toBe(expectedBalance);
});
3. Event-Based Tracking
test("should deduct from multiple features using event_name", async () => {
const deductValue = 45.67;
await autumnV1.track({
customer_id: customerId,
event_name: "action-event", // Triggers action1 AND action2
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
// Both features deducted
expect(customer.features[TestFeature.Action1].balance).toBe(
new Decimal(200).sub(deductValue).toNumber()
);
expect(customer.features[TestFeature.Action2].balance).toBe(
new Decimal(150).sub(deductValue).toNumber()
);
});
4. Credit Systems
Direct Credit Tracking:
test("should deduct from credits directly", async () => {
const deductValue = 27.35;
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Credits,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Credits].balance).toBe(
new Decimal(100).sub(deductValue).toNumber()
);
});
Track Action (Uses Credits with Multiplier):
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
test("should deduct from credits with credit_cost multiplier", async () => {
const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits);
const action1Value = 50.25;
const expectedCreditCost = getCreditCost({
featureId: TestFeature.Action1,
creditSystem: creditFeature!,
amount: action1Value,
});
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: action1Value,
});
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Credits].balance).toBe(
new Decimal(200).sub(expectedCreditCost).toNumber()
);
});
5. Deduction Order (Feature First, Then Credits)
test("should deduct from action1 first, then credits", async () => {
// Product has: action1 (100 units) + credits (200 units)
// First track: only affects action1
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: 40.5,
});
let customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Action1].balance).toBe(59.5);
expect(customer.features[TestFeature.Credits].balance).toBe(200); // Untouched
// Second track: finishes action1, dips into credits
const deductValue = 80;
const remainingAction1 = 59.5;
const overflowAmount = deductValue - remainingAction1;
const creditCostForOverflow = getCreditCost({
featureId: TestFeature.Action1,
creditSystem: creditFeature!,
amount: overflowAmount,
});
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: deductValue,
});
customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Action1].balance).toBe(0); // Depleted
expect(customer.features[TestFeature.Credits].balance).toBe(
new Decimal(200).sub(creditCostForOverflow).toNumber()
);
});
6. Concurrent Requests
test("should handle concurrent requests correctly", async () => {
const initialBalance = 100;
// Send 5 concurrent requests, each trying to deduct 10
const promises = [
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
];
await Promise.all(promises);
const customer = await autumnV1.customers.get(customerId);
const expectedBalance = new Decimal(initialBalance).sub(50).toNumber();
expect(customer.features[TestFeature.Messages].balance).toBe(expectedBalance);
expect(customer.features[TestFeature.Messages].usage).toBe(50);
});
7. Balance Capping
test("should cap balance at 0 with default behavior", async () => {
// Initial balance: 5
// Try to deduct: 50 (more than available)
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Messages].balance).toBe(0); // Capped
expect(customer.features[TestFeature.Messages].usage).toBe(5); // Only deducted what was available
});
Multiple Credit System Pairs
test("should deduct from two credit system pairs simultaneously", async () => {
// Product has:
// - action1 (80) + credits (150)
// - action3 (60) + credits2 (100)
const deductValue = 25.5;
await autumnV1.track({
customer_id: customerId,
event_name: "action-event", // Triggers both action1 and action3
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
// Both actions deducted
expect(customer.features[TestFeature.Action1].balance).toBe(
new Decimal(80).sub(deductValue).toNumber()
);
expect(customer.features[TestFeature.Action3].balance).toBe(
new Decimal(60).sub(deductValue).toNumber()
);
// Credits untouched (actions had enough balance)
expect(customer.features[TestFeature.Credits].balance).toBe(150);
expect(customer.features[TestFeature.Credits2].balance).toBe(100);
});
Required Imports
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type LimitedItem } from "@autumn/shared";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
Test File Template
import { Decimal } from "decimal.js";
const testCase = "track-X";
const customerId = "track-X";
const someFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [someFeature],
});
describe(`${chalk.yellowBright("track-X: description")}`, () => {
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({ ctx, customerId, withTestClock: false });
await initProductsV0({ ctx, products: [freeProd], prefix: testCase });
await autumnV1.attach({ customer_id: customerId, product_id: freeProd.id });
});
test("should have initial balance", async () => {
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Messages].balance).toBe(100);
});
test("should deduct correctly", async () => {
const deductValue = 23.47; // Use random decimals
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
const expectedBalance = new Decimal(100).sub(deductValue).toNumber();
expect(customer.features[TestFeature.Messages].balance).toBe(expectedBalance);
});
});
Key Differences from /check
| Aspect | /check | /track |
|---|---|---|
| Purpose | Validate access | Record usage |
| Modifies Data | No | Yes (deducts balance) |
| Returns | Allowed/balance info | Success/event details |
| Synchronous | Yes | Yes (no timeouts needed) |
| Credit Systems | Check action, shows credit balance | Deducts from action, falls back to credits |
| Concurrency | N/A | Handled with SQL atomicity |
Best Practices
✅ DO
- Use
Decimalfor all balance calculations:new Decimal(100).sub(23.47).toNumber() - Use random decimal values (23.47, 37.89, 50.25) for test robustness
- Test initial balance before tracking
- Test both
feature_idandevent_nameapproaches - Import
getCreditCostwhen testing credit systems - Test deduction order (feature → credits)
- Verify both
balanceandusagefields
❌ DON'T
- Don't use raw arithmetic:
100 - 23.47(floating point errors!) - Don't use timeouts (track is synchronous)
- Don't test on Credits feature directly (test on actions)
- Don't assume balance order without sorting
- Don't forget to test concurrent scenarios
Testing Cached vs Non-Cached Customer Data
After tracking, always verify both the cached and non-cached customer to ensure Redis cache and DB are in sync:
test("should deduct exact value provided", async () => {
const deductValue = 23.47;
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
});
// Check cached customer (immediate)
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Messages].balance).toBe(100 - deductValue);
expect(customer.features[TestFeature.Messages].usage).toBe(deductValue);
});
test("should reflect deduction in non-cached customer after 2s", async () => {
const deductValue = 23.47;
// Wait 2 seconds for DB sync
await timeout(2000);
// Fetch customer with skip_cache=true (direct from DB)
const customer = await autumnV1.customers.get(customerId, {
skip_cache: "true",
});
expect(customer.features[TestFeature.Messages].balance).toBe(100 - deductValue);
expect(customer.features[TestFeature.Messages].usage).toBe(deductValue);
});
Why test both?
- Cached customer: Verifies Redis cache is updated immediately after tracking
- Non-cached customer: Verifies DB write was successful (with 2s delay for batch sync)
- Ensures data consistency across cache layer and database
Checklist
- Unique test case name (e.g., "track-basic1")
- Use chalk for describe block
- Use
Decimalfor balance calculations - Random decimal values for
valueparameter - Initialize in correct order: customer → products → attach
- Test initial balance first
- For credit systems: use
getCreditCosthelper - Verify both
balanceandusagefields - Test concurrent requests when relevant
- Test both cached and non-cached customer (with 2s delay for DB sync)
Common Pitfalls
❌ Floating Point Error
// BAD
expect(balance).toBe(100 - 23.47); // May fail due to floating point
// GOOD
expect(balance).toBe(new Decimal(100).sub(23.47).toNumber());
❌ Testing Credits Directly
// BAD - Tests credit feature directly
await autumnV1.track({
feature_id: TestFeature.Credits,
value: 50,
});
// GOOD - Tests action that uses credits
await autumnV1.track({
feature_id: TestFeature.Action1,
value: 50,
});
// Then check both action1 and credits balances
❌ Forgetting Credit Cost Multiplier
// BAD - Assumes 1:1 deduction
expect(credits.balance).toBe(100 - 50);
// GOOD - Calculates with credit_cost
const expectedCost = getCreditCost({
featureId: TestFeature.Action1,
creditSystem: creditFeature,
amount: 50,
});
expect(credits.balance).toBe(new Decimal(100).sub(expectedCost).toNumber());
Advanced: Testing Deduction Order
When a product has both a metered feature AND a credit system:
- First: Deducts from the metered feature
- Then: When depleted, falls back to credit system
- Credit Cost: Applied when using credit system (not 1:1)
// Setup: action1 (100) + credits (200), credit_cost = 0.2
// Track 40 → only action1 affected
// action1: 60, credits: 200
// Track 80 → finishes action1 (60), then uses credits for remaining 20
// action1: 0, credits: 200 - (20 * 0.2) = 196
// Track 50 → only credits affected
// action1: 0, credits: 196 - (50 * 0.2) = 186