write-test

为 Autumn 计费系统编写符合规范的集成测试,基于 initScenario 模式构建隔离、可并行执行的场景,覆盖订阅变更、余额计算、功能配额验证等核心逻辑,确保计费行为准确可靠。

快捷安装

在终端运行此命令,即可一键安装该 Skill 到您的 Claude 中

npx skills add useautumn/autumn --skill "write-test"

Test Writing Guide

Before Writing ANY Test

  1. Search for duplicate scenarios — grep the test directory for similar setups
  2. Read the rules file .claude/rules/write-tests.mdc — the 20 rules agents ALWAYS get wrong

Minimal Template

import { expect, test } from "bun:test";
import { type ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
import { TestFeature } from "@tests/setup/v2Features.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";

test.concurrent(`${chalk.yellowBright("feature: description")}`, async () => {
  const messagesItem = items.monthlyMessages({ includedUsage: 100 });
  const pro = products.pro({ items: [messagesItem] });

  const { customerId, autumnV1, ctx } = await initScenario({
    customerId: "unique-test-id",
    setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro] })],
    actions: [s.billing.attach({ productId: pro.id })],
  });

  const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
  expectCustomerFeatureCorrect({ customer, featureId: TestFeature.Messages, balance: 100 });
  await expectStripeSubscriptionCorrect({ ctx, customerId });
});

initScenario — The Core System

initScenario creates customers, products, entities, and runs actions sequentially. It returns everything you need.

Returned Values

const {
  customerId,     // Customer ID (auto-prefixed products)
  autumnV1,       // V1.2 API client
  autumnV2,       // V2.0 API client
  ctx,            // { db, stripeCli, org, env, features }
  testClockId,    // Stripe test clock ID
  customer,       // Customer object after creation
  entities,       // [{ id: "ent-1", name: "Entity 1", featureId }]
  advancedTo,     // Current test clock timestamp (ms)
  otherCustomers, // Map<string, OtherCustomerResult>
} = await initScenario({ ... });

Setup Functions

FunctionPurposeNotes
s.customer({ paymentMethod?, testClock?, data?, withDefault?, skipWebhooks? })Configure customertestClock defaults true. Use paymentMethod: "success" for any paid product
s.products({ list, customerIdsToDelete? })Products to createAuto-prefixed with customerId
s.entities({ count, featureId })Generate entitiesCreates “ent-1” through “ent-N”
s.otherCustomers([{ id, paymentMethod? }])Additional customersShare same test clock as primary
s.deleteCustomer({ customerId } | { email })Pre-test cleanupDelete before creating
s.reward({ reward, productId })Standalone rewardID auto-suffixed
s.referralProgram({ reward, program })Referral programIDs auto-suffixed

Action Functions — WITH TIMEOUT BEHAVIOR

CRITICAL: Know which actions have built-in timeouts and which don’t.

FunctionBuilt-in TimeoutNotes
s.billing.attach({ productId, options?, planSchedule?, items?, newBillingSubscription? })5-8sV2 endpoint. Use for new billing tests
s.attach({ productId, entityIndex?, options?, newBillingSubscription? })4-5sV1 endpoint. Use for legacy/update-subscription setup
s.billing.multiAttach({ plans, entityIndex?, freeTrial? })2-5splans: [{ productId, featureQuantities? }]
s.cancel({ productId, entityIndex? })NoneNo timeout
s.track({ featureId, value, entityIndex?, timeout? })NoneMust pass timeout explicitly if needed
s.advanceTestClock({ days?, weeks?, hours?, months? })Waits for StripeCumulative from advancedTo
s.advanceToNextInvoice({ withPause? })30sAdvances 1 month + 96h for invoice finalization
s.updateSubscription({ productId, entityIndex?, cancelAction?, items? })Nonecancel_end_of_cycle, cancel_immediately, uncancel
s.attachPaymentMethod({ type })None”success”, “fail”, “authenticate”
s.removePaymentMethod()NoneRemove all PMs
s.resetFeature({ featureId, productId?, timeout? })2s defaultFor FREE products only. Use s.advanceToNextInvoice for paid
s.referral.createCode()NoneCreate referral code
s.referral.redeem({ customerId })NoneRedeem for another customer

s.billing.attach vs s.attach — They Are DIFFERENT

s.attachs.billing.attach
EndpointV1 /attachV2 /billing.attach
Extra paramsnoneplanSchedule, items (custom plan)
Prepaid quantityExclusive of includedUsageInclusive of includedUsage
Use whenLegacy tests, update-subscription setupNew billing/attach tests

Product ID Prefixing

initScenario mutates product objects in-place: product.id becomes "${product.id}_${customerId}". So pro.id after initScenario already includes the prefix. Use product.id everywhere — in s.attach(), in direct API calls, and in assertions.

Multiple Customers — NEVER Call initScenario Twice

// Use s.otherCustomers in setup
const { autumnV1, otherCustomers } = await initScenario({
  customerId: "cus-a",
  setup: [
    s.customer({ paymentMethod: "success" }),
    s.products({ list: [pro] }),
    s.otherCustomers([{ id: "cus-b", paymentMethod: "success" }]),
  ],
  actions: [s.billing.attach({ productId: pro.id })],
});

// Or create manually after initScenario
await autumnV1.customers.create("cus-b", { name: "B" });
await autumnV1.billing.attach({ customer_id: "cus-b", product_id: pro.id });

Assertion Utilities — ALWAYS Use These

Product State

import { expectCustomerProducts, expectProductActive, expectProductCanceling,
  expectProductScheduled, expectProductNotPresent } from "@tests/integration/billing/utils/expectCustomerProductCorrect";

// PREFERRED — batch check multiple products in one call
await expectCustomerProducts({
  customer,
  active: [pro.id],
  canceling: [premium.id],    // "canceling" = status:active + canceled_at set
  scheduled: [free.id],
  notPresent: [oldProduct.id],
});

// Single product checks
await expectProductActive({ customer, productId: pro.id });
await expectProductCanceling({ customer, productId: premium.id });
await expectProductScheduled({ customer, productId: free.id });
await expectProductNotPresent({ customer, productId: pro.id });

Features

import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";

// IMPORTANT: requires `customer` object, does NOT fetch from API
expectCustomerFeatureCorrect({
  customer,                        // MUST be fetched customer object, not customerId
  featureId: TestFeature.Messages,
  includedUsage: 100,              // optional
  balance: 100,                    // optional
  usage: 0,                        // optional
  resetsAt: advancedTo + ms.days(30), // optional, 10min tolerance
});

Invoices

import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";

expectCustomerInvoiceCorrect({
  customer,          // ApiCustomerV3
  count: 2,          // Total invoice count
  latestTotal: 30,   // Most recent invoice total ($), +-0.01 tolerance
  latestStatus: "paid",
});

Stripe Subscription (ALWAYS call after billing actions)

import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";

// Basic — verify all subscriptions match expected state
await expectStripeSubscriptionCorrect({ ctx, customerId });

// With options
await expectStripeSubscriptionCorrect({
  ctx, customerId,
  options: { subCount: 1, status: "trialing", debug: true },
});

For free products, use expectNoStripeSubscription instead:

import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription";
await expectNoStripeSubscription({ db: ctx.db, customerId, org: ctx.org, env: ctx.env });

Trials

import { expectProductTrialing, expectProductNotTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing";

const trialEndsAt = await expectProductTrialing({
  customer, productId: pro.id, trialEndsAt: advancedTo + ms.days(7),
});
await expectProductNotTrialing({ customer, productId: pro.id });

Preview Next Cycle

import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";

expectPreviewNextCycleCorrect({ preview, startsAt: addMonths(advancedTo, 1).getTime(), total: 20 });
// Or when next_cycle should NOT exist:
expectPreviewNextCycleCorrect({ preview, expectDefined: false });

Proration

import { calculateProratedDiff } from "@tests/integration/billing/utils/proration";

const expected = await calculateProratedDiff({
  customerId, advancedTo, oldAmount: 20, newAmount: 50,
});
expect(preview.total).toBeCloseTo(expected, 0);

Invoice Line Items (for tests verifying stored line items)

import { expectInvoiceLineItemsCorrect, expectBasePriceLineItem } from "@tests/integration/billing/utils/expectInvoiceLineItemsCorrect";

// Full check with per-item expectations
await expectInvoiceLineItemsCorrect({
  stripeInvoiceId: invoice.stripe_id,
  expectedTotal: 20,
  expectedCount: 2,
  expectedLineItems: [
    { isBasePrice: true, amount: 20, direction: "charge" },
    { featureId: TestFeature.Messages, totalAmount: 0 },
  ],
});

// Quick base price check
await expectBasePriceLineItem({ stripeInvoiceId, amount: 20 });

Error Testing

import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";

await expectAutumnError({
  errCode: ErrCode.CustomerNotFound,
  func: () => autumnV1.customers.get("invalid-id"),
});

Cache vs DB Verification

import { expectFeatureCachedAndDb } from "@tests/integration/billing/utils/expectFeatureCachedAndDb";

await expectFeatureCachedAndDb({
  autumn: autumnV1, customerId,
  featureId: TestFeature.Messages, balance: 90, usage: 10,
});

Rollovers

import { expectCustomerRolloverCorrect, expectNoRollovers } from "@tests/integration/billing/utils/rollover/expectCustomerRolloverCorrect";

expectCustomerRolloverCorrect({
  customer, featureId: TestFeature.Messages,
  expectedRollovers: [{ balance: 150 }], totalBalance: 550,
});

Item & Product Fixtures — Quick Reference

Items (@tests/utils/fixtures/items)

ItemFeatureDefaultNotes
items.dashboard()DashboardbooleanOn/off access
items.monthlyMessages({ includedUsage? })Messages100Resets monthly
items.monthlyWords({ includedUsage? })Words100Resets monthly
items.monthlyCredits({ includedUsage? })Credits100Resets monthly
items.monthlyUsers({ includedUsage? })Users5Resets monthly
items.unlimitedMessages()MessagesunlimitedNo cap
items.lifetimeMessages({ includedUsage? })Messages100Never resets (interval: null)
items.prepaidMessages({ includedUsage?, billingUnits?, price? })Messages0, 100, $10Buy upfront in packs
items.prepaid({ featureId, includedUsage?, billingUnits?, price? })any0, 100, $10Generic prepaid
items.prepaidUsers({ includedUsage?, billingUnits? })Users0, 1Per-seat prepaid
items.consumableMessages({ includedUsage? })Messages0$0.10/unit overage
items.consumableWords({ includedUsage? })Words0$0.05/unit overage
items.consumable({ featureId, includedUsage?, price?, billingUnits? })any0, $0.10, 1Generic consumable
items.allocatedUsers({ includedUsage? })Users0$10/seat prorated
items.allocatedWorkflows({ includedUsage? })Workflows0$10/workflow prorated
items.freeAllocatedUsers({ includedUsage? })Users5Free seats (no price)
items.oneOffMessages({ includedUsage?, billingUnits?, price? })Messages0, 100, $10One-time purchase
items.monthlyPrice({ price? })-$20Base price item
items.annualPrice({ price? })-$200Annual base price
items.oneOffPrice({ price? })-$50One-time base price
items.monthlyMessagesWithRollover({ includedUsage?, rolloverConfig })Messages100With rollover
items.tieredPrepaidMessages({ includedUsage?, billingUnits?, tiers? })Messages-Graduated tier prepaid
items.tieredConsumableMessages({ includedUsage?, billingUnits?, tiers? })Messages-Graduated tier consumable

Products (@tests/utils/fixtures/products)

ProductBuilt-in Base PriceDefault ID
products.base({ items, id?, isDefault?, isAddOn? })None (free)“base”
products.pro({ items, id? })$20/mo”pro”
products.premium({ items, id? })$50/mo”premium”
products.growth({ items, id? })$100/mo”growth”
products.ultra({ items, id? })$200/mo”ultra”
products.proAnnual({ items, id? })$200/yr”pro-annual”
products.proWithTrial({ items, id?, trialDays?, cardRequired? })$20/mo + trial”pro-trial”
products.baseWithTrial({ items, id?, trialDays?, cardRequired? })None + trial”base-trial”
products.oneOff({ items, id? })$10 one-time”one-off”
products.recurringAddOn({ items, id? })$20/mo add-on”addon”
products.oneOffAddOn({ items, id? })$10 one-time add-on”one-off-addon”

NEVER add items.monthlyPrice() to products.pro() — it already has $20/mo built in.

Common Test Patterns

Attach Test (Upgrade)

test.concurrent(`${chalk.yellowBright("upgrade: free to pro")}`, async () => {
  const messagesItem = items.monthlyMessages({ includedUsage: 100 });
  const free = products.base({ id: "free", items: [messagesItem] });
  const pro = products.pro({ items: [messagesItem] });

  const { customerId, autumnV1, ctx } = await initScenario({
    customerId: "upgrade-free-pro",
    setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [free, pro] })],
    actions: [s.billing.attach({ productId: free.id })],
  });

  await autumnV1.billing.attach({
    customer_id: customerId, product_id: pro.id, redirect_mode: "if_required",
  });

  const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
  await expectCustomerProducts({ customer, active: [pro.id], notPresent: [free.id] });
  expectCustomerInvoiceCorrect({ customer, count: 1, latestTotal: 20 });
  await expectStripeSubscriptionCorrect({ ctx, customerId });
});

Downgrade Test (Scheduled)

test.concurrent(`${chalk.yellowBright("downgrade: pro to free")}`, async () => {
  const messagesItem = items.monthlyMessages({ includedUsage: 100 });
  const pro = products.pro({ items: [messagesItem] });
  const free = products.base({ id: "free", items: [messagesItem] });

  const { customerId, autumnV1, ctx } = await initScenario({
    customerId: "downgrade-pro-free",
    setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro, free] })],
    actions: [s.billing.attach({ productId: pro.id })],
  });

  await autumnV1.billing.attach({
    customer_id: customerId, product_id: free.id, redirect_mode: "if_required",
  });

  const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
  await expectCustomerProducts({
    customer,
    canceling: [pro.id],   // NOT active — canceling means active + canceled_at set
    scheduled: [free.id],
  });
  await expectStripeSubscriptionCorrect({ ctx, customerId });
});

Track Test (Decimal.js Required)

import { Decimal } from "decimal.js";

test.concurrent(`${chalk.yellowBright("track: basic deduction")}`, async () => {
  const messagesItem = items.monthlyMessages({ includedUsage: 100 });
  const free = products.base({ items: [messagesItem] });

  const { customerId, autumnV1 } = await initScenario({
    customerId: "track-basic",
    setup: [s.customer({}), s.products({ list: [free] })],
    actions: [s.attach({ productId: free.id })],
  });

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

  const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
  expect(customer.features[TestFeature.Messages].balance).toBe(
    new Decimal(100).sub(23.47).toNumber()
  );
});

Prepaid Test

test.concurrent(`${chalk.yellowBright("prepaid: attach with quantity")}`, async () => {
  const prepaidItem = items.prepaidMessages({ includedUsage: 0, billingUnits: 100, price: 10 });
  const pro = products.base({ id: "prepaid-pro", items: [prepaidItem] });

  const { customerId, autumnV1, ctx } = await initScenario({
    customerId: "prepaid-attach",
    setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro] })],
    actions: [
      s.billing.attach({
        productId: pro.id,
        options: [{ feature_id: TestFeature.Messages, quantity: 200 }], // inclusive of includedUsage
      }),
    ],
  });

  const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
  // quantity 200 → rounded to nearest billingUnit (200), purchased_balance: 200
  expectCustomerFeatureCorrect({ customer, featureId: TestFeature.Messages, balance: 200 });
  await expectStripeSubscriptionCorrect({ ctx, customerId });
});

Test Type Decision Tree

Writing a…Use in initScenario actionsTest body calls
Billing attach tests.billing.attach() for setupautumnV1.billing.attach() for action under test
Multi-attach tests.billing.attach() for setupautumnV1.billing.multiAttach()
Update subscription tests.attach() for initial attachautumnV1.subscriptions.update()
Cancel tests.billing.attach() for setupautumnV1.subscriptions.update({ cancel: "end_of_cycle" })
Track/check tests.attach() for product setupautumnV1.track() / autumnV1.check()
Prepaid tests.billing.attach({ options })autumnV1.billing.attach() or subscriptions.update()
Entity tests.entities() in setup, entityIndex in actionsEntity-specific API calls
Webhook tests.customer({ skipWebhooks: true })Manual customer create with skipWebhooks: false

Balance Calculation Rules

Feature TypeBalance FormulaUse Decimal.js?
Free meteredincludedUsage - usageYes
PrepaidincludedUsage + purchasedQuantity - usageYes
Consumable + Prepaid same featureconsumable.includedUsage + prepaid.purchasedQuantity - usageYes
AllocatedincludedUsage + purchasedSeats - currentSeatsYes
Credit systemcreditBalance - sum(action * credit_cost)Yes, + getCreditCost()

Resetting Features: Free vs Paid

  • Free products (no Stripe sub): Use s.resetFeature({ featureId, productId }) — simulates cron job
  • Paid products (has Stripe sub): Use s.advanceToNextInvoice() — advances test clock, triggers invoice.paid webhook

Running Tests

CRITICAL: NEVER run tests automatically. Always ask the user for permission before running any test command. The user likely has a dev server running and needs to coordinate test execution.

Commands (run from repo root)

# Run a single test file
bun test server/tests/integration/billing/attach/my-test.test.ts --timeout 60000

# Run a specific test by name pattern within a file
bun test server/tests/integration/billing/attach/my-test.test.ts -t "upgrade: free to pro" --timeout 60000

# Run all tests in a directory
bun test server/tests/integration/billing/attach/ --timeout 60000

# Using the package.json script (loads env via infisical)
bun run --cwd server test:integration server/tests/integration/billing/attach/my-test.test.ts

Key Points

  • --timeout 60000 (or higher) is essential — billing tests involve Stripe test clocks and can take 30s+
  • bunfig.toml sets timeout = 0 (infinite) and preloads env + test setup automatically
  • Run one test file at a time during development to avoid test clock conflicts
  • All server-side console.log output goes to the server’s logs, not the test output — ask the user to paste server logs if debugging

After Writing Tests

Always run a typecheck:

bun ts

This runs bunx tsgo --build --noEmit in the server directory. Fix all type errors before considering the task done.

References (Load On-Demand for Edge Cases)