write-test by useautumn
Write integration tests for Autumn billing. Covers initScenario setup, billing/attach/track/check endpoints, subscription updates, assertion utilities, and common billing test patterns. Use when creating tests, writing test scenarios, debugging test failures, or when the user asks about testing.
Content & Writing
2.4K Stars
196 Forks
Updated Mar 2, 2026, 10:43 AM
Why Use This
This skill provides specialized capabilities for useautumn's codebase.
Use Cases
- Developing new features in the useautumn repository
- Refactoring existing code to follow useautumn standards
- Understanding and working with useautumn's codebase structure
Install Guide
2 steps- 1
Skip this step if Ananke is already installed.
- 2
Skill Snapshot
Auto scan of skill assets. Informational only.
Valid SKILL.md
Checks against SKILL.md specification
Source & Community
Skill Stats
SKILL.md 511 Lines
Total Files 2
Total Size 21.9 KB
License Apache-2.0
---
name: write-test
description: Write integration tests for Autumn billing. Covers initScenario setup, billing/attach/track/check endpoints, subscription updates, assertion utilities, and common billing test patterns. Use when creating tests, writing test scenarios, debugging test failures, or when the user asks about testing.
---
# 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
```typescript
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
```typescript
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
| Function | Purpose | Notes |
|----------|---------|-------|
| `s.customer({ paymentMethod?, testClock?, data?, withDefault?, skipWebhooks? })` | Configure customer | `testClock` defaults `true`. Use `paymentMethod: "success"` for any paid product |
| `s.products({ list, customerIdsToDelete? })` | Products to create | Auto-prefixed with `customerId` |
| `s.entities({ count, featureId })` | Generate entities | Creates "ent-1" through "ent-N" |
| `s.otherCustomers([{ id, paymentMethod? }])` | Additional customers | Share same test clock as primary |
| `s.deleteCustomer({ customerId } \| { email })` | Pre-test cleanup | Delete before creating |
| `s.reward({ reward, productId })` | Standalone reward | ID auto-suffixed |
| `s.referralProgram({ reward, program })` | Referral program | IDs auto-suffixed |
### Action Functions — WITH TIMEOUT BEHAVIOR
**CRITICAL: Know which actions have built-in timeouts and which don't.**
| Function | Built-in Timeout | Notes |
|----------|-----------------|-------|
| `s.billing.attach({ productId, options?, planSchedule?, items?, newBillingSubscription? })` | **5-8s** | V2 endpoint. Use for new billing tests |
| `s.attach({ productId, entityIndex?, options?, newBillingSubscription? })` | **4-5s** | V1 endpoint. Use for legacy/update-subscription setup |
| `s.billing.multiAttach({ plans, entityIndex?, freeTrial? })` | **2-5s** | `plans: [{ productId, featureQuantities? }]` |
| `s.cancel({ productId, entityIndex? })` | **None** | No timeout |
| `s.track({ featureId, value, entityIndex?, timeout? })` | **None** | Must pass `timeout` explicitly if needed |
| `s.advanceTestClock({ days?, weeks?, hours?, months? })` | Waits for Stripe | Cumulative from `advancedTo` |
| `s.advanceToNextInvoice({ withPause? })` | **30s** | Advances 1 month + 96h for invoice finalization |
| `s.updateSubscription({ productId, entityIndex?, cancelAction?, items? })` | **None** | cancel_end_of_cycle, cancel_immediately, uncancel |
| `s.attachPaymentMethod({ type })` | **None** | "success", "fail", "authenticate" |
| `s.removePaymentMethod()` | **None** | Remove all PMs |
| `s.resetFeature({ featureId, productId?, timeout? })` | **2s default** | For FREE products only. Use `s.advanceToNextInvoice` for paid |
| `s.referral.createCode()` | **None** | Create referral code |
| `s.referral.redeem({ customerId })` | **None** | Redeem for another customer |
### `s.billing.attach` vs `s.attach` — They Are DIFFERENT
| | `s.attach` | `s.billing.attach` |
|---|---|---|
| **Endpoint** | V1 `/attach` | V2 `/billing.attach` |
| **Extra params** | none | `planSchedule`, `items` (custom plan) |
| **Prepaid quantity** | **Exclusive** of `includedUsage` | **Inclusive** of `includedUsage` |
| **Use when** | Legacy tests, update-subscription setup | New 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
```typescript
// 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
```typescript
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
```typescript
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
```typescript
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)
```typescript
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:
```typescript
import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription";
await expectNoStripeSubscription({ db: ctx.db, customerId, org: ctx.org, env: ctx.env });
```
### Trials
```typescript
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
```typescript
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
```typescript
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)
```typescript
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
```typescript
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
await expectAutumnError({
errCode: ErrCode.CustomerNotFound,
func: () => autumnV1.customers.get("invalid-id"),
});
```
### Cache vs DB Verification
```typescript
import { expectFeatureCachedAndDb } from "@tests/integration/billing/utils/expectFeatureCachedAndDb";
await expectFeatureCachedAndDb({
autumn: autumnV1, customerId,
featureId: TestFeature.Messages, balance: 90, usage: 10,
});
```
### Rollovers
```typescript
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`)
| Item | Feature | Default | Notes |
|------|---------|---------|-------|
| `items.dashboard()` | Dashboard | boolean | On/off access |
| `items.monthlyMessages({ includedUsage? })` | Messages | 100 | Resets monthly |
| `items.monthlyWords({ includedUsage? })` | Words | 100 | Resets monthly |
| `items.monthlyCredits({ includedUsage? })` | Credits | 100 | Resets monthly |
| `items.monthlyUsers({ includedUsage? })` | Users | 5 | Resets monthly |
| `items.unlimitedMessages()` | Messages | unlimited | No cap |
| `items.lifetimeMessages({ includedUsage? })` | Messages | 100 | Never resets (interval: null) |
| `items.prepaidMessages({ includedUsage?, billingUnits?, price? })` | Messages | 0, 100, $10 | Buy upfront in packs |
| `items.prepaid({ featureId, includedUsage?, billingUnits?, price? })` | any | 0, 100, $10 | Generic prepaid |
| `items.prepaidUsers({ includedUsage?, billingUnits? })` | Users | 0, 1 | Per-seat prepaid |
| `items.consumableMessages({ includedUsage? })` | Messages | 0 | $0.10/unit overage |
| `items.consumableWords({ includedUsage? })` | Words | 0 | $0.05/unit overage |
| `items.consumable({ featureId, includedUsage?, price?, billingUnits? })` | any | 0, $0.10, 1 | Generic consumable |
| `items.allocatedUsers({ includedUsage? })` | Users | 0 | $10/seat prorated |
| `items.allocatedWorkflows({ includedUsage? })` | Workflows | 0 | $10/workflow prorated |
| `items.freeAllocatedUsers({ includedUsage? })` | Users | 5 | Free seats (no price) |
| `items.oneOffMessages({ includedUsage?, billingUnits?, price? })` | Messages | 0, 100, $10 | One-time purchase |
| `items.monthlyPrice({ price? })` | - | $20 | Base price item |
| `items.annualPrice({ price? })` | - | $200 | Annual base price |
| `items.oneOffPrice({ price? })` | - | $50 | One-time base price |
| `items.monthlyMessagesWithRollover({ includedUsage?, rolloverConfig })` | Messages | 100 | With rollover |
| `items.tieredPrepaidMessages({ includedUsage?, billingUnits?, tiers? })` | Messages | - | Graduated tier prepaid |
| `items.tieredConsumableMessages({ includedUsage?, billingUnits?, tiers? })` | Messages | - | Graduated tier consumable |
### Products (`@tests/utils/fixtures/products`)
| Product | Built-in Base Price | Default 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)
```typescript
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)
```typescript
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)
```typescript
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
```typescript
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` actions | Test body calls |
|---|---|---|
| **Billing attach test** | `s.billing.attach()` for setup | `autumnV1.billing.attach()` for action under test |
| **Multi-attach test** | `s.billing.attach()` for setup | `autumnV1.billing.multiAttach()` |
| **Update subscription test** | `s.attach()` for initial attach | `autumnV1.subscriptions.update()` |
| **Cancel test** | `s.billing.attach()` for setup | `autumnV1.subscriptions.update({ cancel: "end_of_cycle" })` |
| **Track/check test** | `s.attach()` for product setup | `autumnV1.track()` / `autumnV1.check()` |
| **Prepaid test** | `s.billing.attach({ options })` | `autumnV1.billing.attach()` or `subscriptions.update()` |
| **Entity test** | `s.entities()` in setup, `entityIndex` in actions | Entity-specific API calls |
| **Webhook test** | `s.customer({ skipWebhooks: true })` | Manual customer create with `skipWebhooks: false` |
## Balance Calculation Rules
| Feature Type | Balance Formula | Use Decimal.js? |
|---|---|---|
| Free metered | `includedUsage - usage` | Yes |
| Prepaid | `includedUsage + purchasedQuantity - usage` | Yes |
| Consumable + Prepaid same feature | `consumable.includedUsage + prepaid.purchasedQuantity - usage` | Yes |
| Allocated | `includedUsage + purchasedSeats - currentSeats` | Yes |
| Credit system | `creditBalance - 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)
```bash
# 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:
```bash
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)
- [references/SCENARIO.md](references/SCENARIO.md) — Full initScenario details, all builder params
- [references/FIXTURES.md](references/FIXTURES.md) — Complete item/product fixture params
- [references/ENTITIES.md](references/ENTITIES.md) — Entity-based testing (entity-products vs per-entity features)
- [references/EXPECTATIONS.md](references/EXPECTATIONS.md) — All expectation utility signatures
- [references/PRORATION.md](references/PRORATION.md) — Proration calculation utilities
- [references/GOTCHAS.md](references/GOTCHAS.md) — Expanded wrong/right examples for every common mistake
- [references/TRACK-CHECK.md](references/TRACK-CHECK.md) — Track/check endpoint testing, credit systems
- [references/WEBHOOKS.md](references/WEBHOOKS.md) — Outbound webhook testing with Svix Play
- [references/STRIPE-BEHAVIORS.md](references/STRIPE-BEHAVIORS.md) — Stripe webhook behaviors
Name Size