Files
cfw-autumn/.claude/skills/write-test/references/TRACK-CHECK.md
2026-01-16 09:16:26 +00:00

9.4 KiB

Track and Check Endpoint Testing

Overview

Endpoint Purpose Modifies Data
/check Validate feature access, get balance No
/track Record usage, deduct from balance Yes

The /check Endpoint

Parameters

  • customer_id (required) - Customer to check
  • feature_id (required) - Feature to check access for
  • required_balance (optional) - Amount needed (default: 1)

Basic Usage

const res = await autumnV1.check({
  customer_id: customerId,
  feature_id: TestFeature.Messages,
  required_balance: 100,
});

expect(res.allowed).toBe(true);
expect(res.balance).toBe(1000);
expect(res.required_balance).toBe(100);
expect(res.usage).toBe(0);
expect(res.included_usage).toBe(1000);

v0 vs v1 Response Formats

v0 Response:

const res = await autumnV0.check({...}) as CheckResponseV0;

expect(res.allowed).toBe(true);
expect(res.balances).toHaveLength(1);
expect(res.balances[0]).toMatchObject({
  feature_id: TestFeature.Messages,
  balance: 1000,
  required: 100,
});

v1 Response:

const res = await autumnV1.check({...}) as CheckResponse;

expect(res).toMatchObject({
  allowed: true,
  customer_id: customerId,
  feature_id: TestFeature.Messages,
  balance: 1000,
  required_balance: 100,
  code: SuccessCode.FeatureFound,
  usage: 0,
  included_usage: 1000,
  overage_allowed: false,
});
expect(res.next_reset_at).toBeDefined();

The /track Endpoint

Parameters

  • customer_id (required) - Customer to track usage for
  • feature_id OR event_name (required) - Feature or event to track
  • value (optional) - Amount to track (default: 1)
  • entity_id (optional) - For entity-scoped tracking

Basic Usage

await autumnV1.track({
  customer_id: customerId,
  feature_id: TestFeature.Messages,
  value: 10,
});

const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Messages].balance).toBe(90);
expect(customer.features[TestFeature.Messages].usage).toBe(10);

Critical: Use Decimal.js for Balance Calculations

Floating point errors are common! Always use Decimal for calculations:

import { Decimal } from "decimal.js";

// WRONG - Floating point error risk
expect(balance).toBe(100 - 23.47);

// CORRECT - Use Decimal
const expectedBalance = new Decimal(100).sub(23.47).toNumber();
expect(balance).toBe(expectedBalance);

Event-Based Tracking

Track multiple features with one event:

// Both Action1 and Action2 listen to "action-event"
await autumnV1.track({
  customer_id: customerId,
  event_name: "action-event",  // Triggers BOTH features
  value: 10,
});

const customer = await autumnV1.customers.get(customerId);
// Both features deducted
expect(customer.features[TestFeature.Action1].balance).toBe(
  new Decimal(200).sub(10).toNumber()
);
expect(customer.features[TestFeature.Action2].balance).toBe(
  new Decimal(150).sub(10).toNumber()
);

Credit Systems

Key Concept

Credit systems are pools that multiple actions can consume from. Actions have a credit_cost multiplier.

TestFeature schema:

  • Action1 → consumes from Credits with credit_cost = 0.2
  • Action2 → consumes from Credits with credit_cost = 0.6
  • Action3 → consumes from Credits2 with credit_cost = 1.4

Check on Actions, Not Credits

// WRONG - Don't check Credits directly
const res = await autumnV1.check({
  customer_id: customerId,
  feature_id: TestFeature.Credits,  // Wrong!
});

// CORRECT - Check the action, response includes Credits balance
const res = await autumnV1.check({
  customer_id: customerId,
  feature_id: TestFeature.Action1,
  required_balance: 50,
});
expect(res.allowed).toBe(true);
expect(res.balance).toBe(100);  // Shows Credits balance

Deduction Order

When tracking an action that has both a direct balance AND a credit system fallback:

  1. First: Deducts from the action's direct balance
  2. Then: When depleted, falls back to credit system (with credit_cost multiplier)
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";

// Setup: action1 (100) + credits (200), credit_cost = 0.2

// Track 40 → only action1 affected
await autumnV1.track({
  customer_id: customerId,
  feature_id: TestFeature.Action1,
  value: 40,
});
// action1: 60, credits: 200 (untouched)

// Track 80 → finishes action1 (60 remaining), uses credits for overflow (20)
await autumnV1.track({
  customer_id: customerId,
  feature_id: TestFeature.Action1,
  value: 80,
});

// Calculate credit cost for overflow
const creditFeature = ctx.features.find(f => f.id === TestFeature.Credits);
const creditCost = getCreditCost({
  featureId: TestFeature.Action1,
  creditSystem: creditFeature!,
  amount: 20,  // overflow amount
});

// action1: 0, credits: 200 - creditCost
expect(customer.features[TestFeature.Action1].balance).toBe(0);
expect(customer.features[TestFeature.Credits].balance).toBe(
  new Decimal(200).sub(creditCost).toNumber()
);

getCreditCost Utility

import { getCreditCost } from "@/internal/features/creditSystemUtils.js";

const cost = getCreditCost({
  featureId: TestFeature.Action1,
  creditSystem: creditFeature,
  amount: 50,
});
// If credit_cost = 0.2, cost = 50 * 0.2 = 10 credits

Cache vs Database Verification

After tracking, verify both cached and database state:

test("should update both cache and database", async () => {
  await autumnV1.track({
    customer_id: customerId,
    feature_id: TestFeature.Messages,
    value: 10,
  });

  // Check cached (immediate)
  const cached = await autumnV1.customers.get(customerId);
  expect(cached.features[TestFeature.Messages].balance).toBe(90);

  // Wait for DB sync
  await new Promise(r => setTimeout(r, 2000));

  // Check database (skip cache)
  const fromDb = await autumnV1.customers.get(customerId, {
    skip_cache: "true",
  });
  expect(fromDb.features[TestFeature.Messages].balance).toBe(90);
});

Concurrent Request Testing

Track handles concurrent requests with SQL-level atomicity:

test("should handle concurrent requests", async () => {
  const promises = Array(5).fill(null).map(() =>
    autumnV1.track({
      customer_id: customerId,
      feature_id: TestFeature.Messages,
      value: 10,
    })
  );

  await Promise.all(promises);

  const customer = await autumnV1.customers.get(customerId);
  // All 5 requests should be atomic: 100 - 50 = 50
  expect(customer.features[TestFeature.Messages].balance).toBe(50);
  expect(customer.features[TestFeature.Messages].usage).toBe(50);
});

Multiple Products Gotchas

Unique Product IDs Required

// WRONG - Same default ID
const prod1 = constructProduct({ type: "free", items: [feature1] });
const prod2 = constructProduct({ type: "free", items: [feature2] });

// CORRECT - Unique IDs
const prod1 = constructProduct({ type: "free", id: "prod1", items: [feature1] });
const prod2 = constructProduct({ type: "free", id: "prod2", items: [feature2] });

Second Product Needs isAddOn: true

Without isAddOn: true, attaching a second product replaces the first:

// WRONG - prod2 replaces prod1
const prod1 = constructProduct({ type: "free", id: "prod1", ... });
const prod2 = constructProduct({ type: "free", id: "prod2", ... });

// CORRECT - prod2 is an add-on
const prod1 = constructProduct({ type: "free", id: "prod1", ... });
const prod2 = constructProduct({ type: "free", id: "prod2", isAddOn: true, ... });

Lifetime/One-Off Reset Format

For features with no reset interval, the reset object is NOT null:

// WRONG
expect(breakdown).toMatchObject({ reset: null });

// CORRECT
expect(breakdown).toMatchObject({
  reset: {
    interval: "one_off",
    resets_at: null,
  },
});

Required Imports

import { beforeAll, describe, expect, test } from "bun:test";
import {
  ApiVersion,
  type CheckResponse,
  type CheckResponseV0,
  type LimitedItem,
  SuccessCode,
} 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,
  constructArrearItem,
  constructPrepaidItem,
} 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 Checklist

/check Tests

  • Test both v0 and v1 response formats
  • Test allowed: true and allowed: false cases
  • Test required_balance parameter
  • For credit systems: check Action, verify Credits balance in response
  • Verify next_reset_at is defined for time-based features

/track Tests

  • Use Decimal.js for ALL balance calculations
  • Use random decimal values (23.47, 37.89) for robustness
  • Test initial balance before tracking
  • Test both feature_id and event_name approaches
  • Verify both balance and usage fields
  • Test concurrent requests
  • Verify both cached and non-cached customer (with 2s delay)
  • For credit systems: use getCreditCost, test deduction order