Files
cfw-autumn/.claude/rules/write-tests.mdc
2026-04-03 16:00:36 +01:00

265 lines
11 KiB
Plaintext

---
alwaysApply: true
---
## Test Writing Rules — NEVER Get These Wrong
### 1. NEVER Call `initScenario` Twice
```typescript
// WRONG — calling initScenario twice for multiple customers
const { autumnV1: a } = await initScenario({ customerId: "cus-a", ... });
const { autumnV1: b } = await initScenario({ customerId: "cus-b", ... }); // BREAKS
// RIGHT — single initScenario, create additional customers manually
const { autumnV1, ctx } = await initScenario({
customerId: "cus-a",
setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro] })],
actions: [s.billing.attach({ productId: pro.id })],
});
await autumnV1.customers.create("cus-b", { name: "Customer B" });
await autumnV1.billing.attach({ customer_id: "cus-b", product_id: pro.id });
// ALSO RIGHT — use s.otherCustomers for additional customers sharing the same test clock
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 })],
});
```
### 1.1. If You Are Testing Customer Creation, Do NOT Pass `customerId` to `initScenario`
`initScenario({ customerId, ... })` creates or registers that customer as part of setup. If the test itself is meant to call `autumn.customers.create(...)`, leave `customerId` out of `initScenario` or you will be testing re-create behavior instead of create behavior.
### 2. `s.billing.attach` and `s.attach` Already Have Timeouts
Both `s.billing.attach` (5-8s) and `s.attach` (4-5s) sleep after the API call. Do NOT add extra `await timeout()` after `initScenario` that already uses these in `actions`. Only add manual timeouts when calling `autumnV1.billing.attach()` directly in the test body.
### 3. `s.track()` Has NO Built-In Timeout
Unlike attach, `s.track()` fires and moves on immediately. If you need side effects to settle (e.g., before an attach that rebuilds from Postgres), pass `timeout` explicitly:
```typescript
s.track({ featureId: TestFeature.Messages, value: 50, timeout: 2000 })
```
### 4. `s.billing.attach` Is NOT the Same as `s.attach`
| | `s.attach` | `s.billing.attach` |
|---|---|---|
| Endpoint | V1 `/attach` | V2 `/billing.attach` |
| Extra params | none | `planSchedule`, `items` |
| Use for | Legacy tests, simple setup | Scenario setup for billing tests |
For the action under test, prefer direct client calls like `autumnV2_2.billing.previewAttach<AttachParamsV1Input>(...)`, `autumnV2_2.billing.attach<AttachParamsV1Input>(...)`, and `autumnV2_2.subscriptions.update<UpdateSubscriptionV1ParamsInput>(...)`.
### 5. Prepaid Quantity: Inclusive vs Exclusive of `includedUsage`
- **`s.billing.attach` (new V2)**: `quantity` is **inclusive** of `includedUsage`
- **`s.attach` (legacy V1)**: `quantity` is **exclusive** of `includedUsage`
### 6. `includedUsage` Must Be a Multiple of `billingUnits`
Stripe requires integer tier values. `includedUsage: 50` with `billingUnits: 100` = 0.5, which Stripe rejects.
### 7. `products.pro()` Already Includes $20/mo Base Price
Don't also add `items.monthlyPrice()` — you'll get double pricing. Same for `products.premium()` ($50/mo), `products.growth()` ($100/mo), `products.ultra()` ($200/mo).
### 8. Always Use `product.id`, Never String Literals
```typescript
// WRONG
s.attach({ productId: "pro" })
// RIGHT
s.attach({ productId: pro.id })
```
`initScenario` auto-prefixes product IDs with `customerId`. The product object's `.id` is mutated to include the prefix.
### 9. Always Use `test.concurrent()`, Never Plain `test()`
### 10. `expectCustomerFeatureCorrect` Requires `customer` Object
It does NOT fetch from the API. Passing only `customerId` without `customer` silently returns undefined features. Always pass the fetched customer object.
### 11. Lifetime/One-Off Breakdowns Use `ResetInterval.OneOff`, Not `null`
```typescript
// WRONG
breakdowns.find(b => b.reset?.interval === null)
// RIGHT
breakdowns.find(b => b.reset?.interval === ResetInterval.OneOff)
```
### 12. Assertion Style
- `toMatchObject` for comparing multiple properties at once
- `toEqual` for single-value comparisons (not `toMatchObject({ prop: value })`)
- `items.free()` for free metered features, not `items.consumable()` with `price: 0`
### 13. `Date.now()` Doesn't Change With Test Clocks
Always use `advancedTo` from `initScenario`:
```typescript
// WRONG
expect(trialEndsAt).toBeCloseTo(Date.now() + ms.days(14));
// RIGHT
expect(trialEndsAt).toBeCloseTo(advancedTo + ms.days(14));
```
### 14. Consumable + Prepaid on Same Feature: `includedUsage` Is the SUM
If a product has both consumable (includedUsage: 50) and prepaid (quantity: 100) for the same feature, total `included_usage` = 150, not 100.
### 15. "Canceling" Is NOT a Status Value
A canceling product has `status: "active"` with `canceled_at` set. Use `expectProductCanceling`, not `expect(status).toBe("canceling")`.
### 16. Always Call `expectStripeSubscriptionCorrect` After Billing Actions
After any `billing.attach()` or `subscriptions.update()` in new tests:
```typescript
await expectStripeSubscriptionCorrect({ ctx, customerId });
```
### 17. Tiered Pricing Must End With `"inf"`
```typescript
// WRONG — Stripe rejects without catch-all
tiers: [{ to: 500, amount: 10 }]
// RIGHT
tiers: [{ to: 500, amount: 10 }, { to: "inf", amount: 5 }]
```
### 18. Setup Actions Go in `initScenario`, Test Body Has Only the Action Under Test
```typescript
// WRONG — multiple attaches in test body
const { autumnV2_2 } = await initScenario({ actions: [] });
await autumnV2_2.billing.attach<AttachParamsV1Input>({ plan_id: pro.id }); // should be setup
await autumnV2_2.billing.attach<AttachParamsV1Input>({ plan_id: addon.id }); // the actual test
// RIGHT — prerequisite in initScenario, only tested action in body
const { autumnV2_2 } = await initScenario({
actions: [s.billing.attach({ productId: pro.id })],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: addon.id,
});
```
### 19. Type Check After Writing Tests
Run `bun ts` in the `server/` directory after writing or editing test files. This runs `bunx tsgo --build --noEmit`. Fix all type errors before considering the task done.
### 20. Use Generic Types With AutumnInt, Not `as unknown as`
```typescript
// WRONG
const customer = await autumnV1.customers.get(customerId) as unknown as ApiCustomerV3;
// RIGHT
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
```
| Client | customers.get | entities.get | check |
|--------|---------------|--------------|-------|
| `autumnV1` | `ApiCustomerV3` | `ApiEntityV0` | `CheckResponseV1` |
| `autumnV2` | `ApiCustomer` | `ApiEntityV1` | `CheckResponseV2` |
### 21. NEVER Run Tests Without Asking
Always ask the user for permission before running any test command. The user likely has a dev server running and needs to coordinate. Present the exact command you plan to run and wait for approval.
### 22. Use the Correct Param Types Per API Client Version
Each client version (`autumnV1`, `autumnV2`, `autumnV2_1`, `autumnV2_2`) expects different input/output types. Always pass the right types as generics and for local variables.
Billing endpoints are not strongly typed enough on their own. ALWAYS supply explicit generics on `billing.previewAttach(...)`, `billing.attach(...)`, and `subscriptions.update(...)`.
| Client | API version | Attach input | Attach output | Update subscription input |
|--------|------------|--------------|---------------|--------------------------|
| `autumnV1` | V1 (`/attach`, `/billing.attach`) | `AttachParamsV0Input` | `ApiCustomerV3` | `UpdateSubscriptionV0Params` |
| `autumnV2` | V2 (`/billing.attach`) | `AttachParamsV1Input` | `ApiCustomer` | `UpdateSubscriptionV1Params` |
| `autumnV2_1` | V2.1 (`/attach`, `/update_subscription`) | `AttachParamsV1Input` | `ApiCustomerV5` | `UpdateSubscriptionV1ParamsInput` |
| `autumnV2_2` | V2.2 (`/attach`, `/update_subscription`) | `AttachParamsV1Input` | `ApiCustomerV5` | `UpdateSubscriptionV1ParamsInput` |
Key differences between `AttachParamsV0Input` and `AttachParamsV1Input`:
- V0 (`autumnV1`): uses `product_id` + `options: [{ feature_id, quantity }]`
- V1 (`autumnV2`): uses `plan_id` + `feature_quantities: [{ feature_id, quantity }]`
```typescript
// ✅ CORRECT — autumnV1 uses AttachParamsV0Input
const params: AttachParamsV0Input = {
customer_id: customerId,
product_id: pro.id, // NOT plan_id
options: [{ feature_id: "messages", quantity: 200 }], // NOT feature_quantities
};
await autumnV1.billing.attach<AttachParamsV0Input>(params);
// ✅ CORRECT — autumnV2 uses AttachParamsV1Input
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: pro.id, // NOT product_id
feature_quantities: [{ feature_id: "messages", quantity: 200 }], // NOT options
};
await autumnV2.billing.attach<AttachParamsV1Input>(params);
// ✅ CORRECT — autumnV2_2 uses V1-style attach/update-subscription params
await autumnV2_2.billing.previewAttach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
feature_quantities: [{ feature_id: "messages", quantity: 200 }],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
feature_quantities: [{ feature_id: "messages", quantity: 200 }],
});
await autumnV2_2.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
customer_id: customerId,
plan_id: pro.id,
redirect_mode: "always",
});
// ❌ WRONG — mixing V1 param names with autumnV1 client
await autumnV1.billing.attach({ customer_id, plan_id: pro.id, feature_quantities: [...] });
```
Gotcha: if a test uses V1-style `attach` / `update_subscription` params like `plan_id`, `feature_quantities`, or `redirect_mode`, prefer `autumnV2_2` and keep the explicit generic on the billing call.
### 23. Prefer `autumnV2_2` + `expectBalanceCorrect` for Balance Assertions
When a test is validating balances and the latest API version can be used, prefer `autumnV2_2` and assert with `@server/tests/integration/utils/expectBalanceCorrect.ts`.
- Use `autumnV2_2.customers.get<ApiCustomerV5>(customerId)` where possible for latest-version balance assertions
- Prefer `expectBalanceCorrect(...)` over older balance helpers when validating balance buckets, breakdowns, or rollovers
- Keep using older clients only when the endpoint/param shape under test specifically requires them
### 24. `autumnV2_2` Does NOT Return Negative `remaining`
For `ApiCustomerV5` / `autumnV2_2` balance responses:
- `remaining` does not go below `0`
- To assert overage, inspect `usage` (and breakdown buckets) instead of expecting negative `remaining`
```typescript
// ❌ WRONG - latest API does not expose negative remaining
expect(customer.balances.messages.remaining).toBe(-50)
// ✅ RIGHT - assert remaining floor + overage usage
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 0,
usage: 150,
})
```