Files
cfw-autumn/server/tests/_guides/track-endpoint-tests.md
John Yeo 6ec4ce0c9e wip
2025-11-03 17:36:41 +00:00

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 for
  • feature_id OR event_name (required) - The feature or event to track
  • value (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 after attach() call
  • type: "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 Decimal for 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_id and event_name approaches
  • Import getCreditCost when testing credit systems
  • Test deduction order (feature → credits)
  • Verify both balance and usage fields

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 Decimal for balance calculations
  • Random decimal values for value parameter
  • Initialize in correct order: customer → products → attach
  • Test initial balance first
  • For credit systems: use getCreditCost helper
  • Verify both balance and usage fields
  • 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:

  1. First: Deducts from the metered feature
  2. Then: When depleted, falls back to credit system
  3. 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