7.5 KiB
Test Gotchas
Quick reference for common mistakes. Each gotcha follows the format:
- Wrong / Right examples
- Brief explanation if needed
Setup & Initialization
Payment Method Required for Paid Features
// WRONG
s.customer({})
// RIGHT
s.customer({ paymentMethod: "success" })
Required for: overage pricing, per-seat, usage-based billing, any base price.
Product IDs - Use Variables, Not Strings
// WRONG
s.attach({ productId: "pro" })
// RIGHT
s.attach({ productId: pro.id })
Products are prefixed by initScenario. Always use product.id.
Multiple Products Need Unique IDs
// WRONG - Same default ID
const prod1 = constructProduct({ type: "free", items: [...] });
const prod2 = constructProduct({ type: "free", items: [...] });
// RIGHT
const prod1 = constructProduct({ type: "free", id: "prod1", items: [...] });
const prod2 = constructProduct({ type: "free", id: "prod2", isAddOn: true, items: [...] });
Without isAddOn: true, second product replaces the first.
Product Fixtures with Built-in Base Price
products.pro, products.proWithTrial, etc. already include a base price. Only products.base has no base price.
// WRONG - Double base price (pro already has $20/mo)
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.pro({
id: "pro",
items: [messagesItem, priceItem], // Now has TWO base prices!
});
// RIGHT - Use products.base when you need a reference to the price item
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
id: "pro",
items: [messagesItem, priceItem],
});
// Now priceItem.price! can be used in assertions
// ALSO RIGHT - Use pro/proWithTrial if you don't need the price reference
const pro = products.pro({ id: "pro", items: [messagesItem] });
const proTrial = products.proWithTrial({ id: "pro", items: [messagesItem], trialDays: 14 });
API & Types
Error Testing - Use expectAutumnError
// WRONG
try { await autumn.customers.get("invalid"); } catch { errorThrown = true; }
// RIGHT
await expectAutumnError({
errCode: ErrCode.CustomerNotFound,
func: () => autumn.customers.get("invalid"),
});
Entities Use customer: Not entity:
// WRONG
expectCustomerFeatureCorrect({ entity: entityData, ... });
// RIGHT
expectCustomerFeatureCorrect({ customer: entityData, ... });
expectCustomerFeatureCorrect accepts both ApiCustomerV3 and ApiEntityV0 via the customer param.
Lifetime Interval: null vs "one_off"
// Constructing: use null
constructFeatureItem({ interval: null });
// In API responses: use ResetInterval.OneOff
breakdown.find(b => b.reset?.interval === ResetInterval.OneOff);
// NOT: b.reset?.interval === null (won't match!)
Timing & Sync
Test Clock: Date.now() Doesn't Change
// WRONG
expect(trialEndsAt).toBeCloseTo(Date.now() + ms.days(14));
// RIGHT
expect(trialEndsAt).toBeCloseTo(advancedTo + ms.days(14));
Wait After Track Before Attach
// WRONG - Cache gets stale data
await autumnV1.track({ ... });
await autumnV1.attach({ ... });
// RIGHT
await autumnV1.track({ ... });
await new Promise(r => setTimeout(r, 2000));
await autumnV1.attach({ ... });
track syncs to Postgres async; attach rebuilds from Postgres.
Cache vs Database
const cached = await autumnV1.customers.get(customerId);
await new Promise(r => setTimeout(r, 2000));
const fromDb = await autumnV1.customers.get(customerId, { skip_cache: "true" });
Prepaid Features
Quantity Required on Attach
// WRONG
s.attach({ productId: pro.id })
// RIGHT
s.attach({ productId: pro.id, options: [{ feature_id: TestFeature.Messages, quantity: 200 }] })
Quantity Rounds UP to Billing Units
// billingUnits: 100
// quantity: 50 → 100 credits
// quantity: 150 → 200 credits
Quantity Goes to purchased_balance
// includedUsage: 0, billingUnits: 100, quantity: 50
// granted_balance: 0, purchased_balance: 100, current_balance: 100
Consumable + Prepaid on Same Feature: Balances Sum
When a product has both consumable and prepaid items for the same feature, they create separate breakdown entries that sum together:
// consumableItem with includedUsage: 50
// prepaidItem with quantity: 100
// Results in TWO breakdown entries for the same feature:
// - Consumable: granted_balance: 50, purchased_balance: 0
// - Prepaid: granted_balance: 0, purchased_balance: 100
// Aggregated totals:
// - granted_balance: 50
// - purchased_balance: 100
// - current_balance: 150 (sum of both)
// - included_usage: 150 (NOT just prepaid quantity!)
// WRONG
expectCustomerFeatureCorrect({
customer: entity,
featureId: TestFeature.Messages,
includedUsage: 100, // Only prepaid quantity
balance: 100,
});
// RIGHT
expectCustomerFeatureCorrect({
customer: entity,
featureId: TestFeature.Messages,
includedUsage: 150, // 50 (consumable) + 100 (prepaid)
balance: 150,
});
Billing & Invoices
Consumable Overage: Not Charged on Update
expect(preview.total).toBe(0); // Even with existing overage
Overage billed at cycle end, not on subscription update.
Allocated Features: Invoice on Track
// 3 included seats, track 5 → 2 overage
// Invoice count = attach (1) + overage (1) = 2
Prepaid Charge = Diff, Not Total
// Old: 2 packs @ $10 = $20
// New: 5 packs @ $10 = $50
// preview.total = $30 (NOT $50)
Free-to-Free: Skip Subscription Check
// Free products have no Stripe subscription
expectCustomerFeatureCorrect({ ... });
// Don't call expectSubToBeCorrect
Product States
Canceling/Downgrading is NOT a Status
// WRONG - Checking status for canceling products
expect(product.status).toBe("canceling"); // "canceling" is not a valid status!
// RIGHT - Use expectProductCanceling helper
await expectProductCanceling({ customer, productId: premium.id });
A product that is canceling or downgrading has status: "active" with canceled_at set. The "canceling" state is a derived state, not a status value.
After Downgrade (A → B)
- Product A: "canceling" (
status: "active",canceled_atset) → useexpectProductCanceling - Product B: "scheduled" (
status: "scheduled") → useexpectProductScheduled
After billing cycle:
- Product A: removed
- Product B: "active"
expectProductCanceling Works with Entities
// For entities, pass the entity data to the customer param
const entity1Data = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductCanceling({
customer: entity1Data,
productId: premium.id,
});
Misc
Server Logs Not Visible in Tests
Console logs in server code don't appear in test output. Check server logs directly.
Decimal.js for Balance Math
// WRONG
expect(balance).toBe(100 - 23.47);
// RIGHT
expect(balance).toBe(new Decimal(100).sub(23.47).toNumber());
Quick Reference
| Context | Import |
|---|---|
| Test context | import ctx from "@tests/utils/testInitUtils/createTestContext.js" |
| Error testing | import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js" |
| Error codes | import { ErrCode } from "@autumn/shared" |
| Reset intervals | import { ResetInterval } from "@autumn/shared" |
| Decimal math | import { Decimal } from "decimal.js" |