skillZs
LIVE SKILL TAGS
>>> LIVE SKILLS INDEX <<<
* OPEN SOURCE *
NO LOGIN, NO TRACKING
REAL INSTALL DATA
← back to all skills
smithery.ai2 installs

write-test

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.

How do I install this agent skill?

npx skills add https://smithery.ai --skill write-test
view source ↗

Is this agent skill safe to install?

No partner audit is available yet. Read the source before installing.

What does this agent skill do?

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)

Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.

<a href="https://skillzs.dev/skills/smithery.ai/write-test">View write-test on skillZs</a>