410 lines
11 KiB
Markdown
410 lines
11 KiB
Markdown
# Test Writing Guide
|
|
|
|
## Test Style
|
|
|
|
- **Always use `test.concurrent()`** - self-contained tests that can run in parallel
|
|
- **Never use `describe/beforeAll/test`** - avoid shared state between tests
|
|
- **Keep setup inline** - each test should be fully self-contained
|
|
|
|
## Quick Start
|
|
|
|
Use `initScenario` with the scenario builder (`s.*`) for test setup:
|
|
|
|
```typescript
|
|
import { expect, test } from "bun:test";
|
|
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("my-feature: descriptive test name")}`, async () => {
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
|
|
const free = products.base({ items: [messagesItem] });
|
|
|
|
const { customerId, autumnV1, ctx } = await initScenario({
|
|
customerId: "my-unique-test-id",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }), // testClock defaults to true
|
|
s.products({ list: [free] }),
|
|
],
|
|
actions: [s.attach({ productId: "base" })],
|
|
});
|
|
|
|
// Your test logic here
|
|
await autumnV1.track({
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: 100,
|
|
});
|
|
|
|
const customer = await autumnV1.customers.get(customerId);
|
|
expect(customer.features[TestFeature.Messages].balance).toBe(400);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Fixtures
|
|
|
|
### Item Fixtures (`@tests/utils/fixtures/items`)
|
|
|
|
Pre-configured product items for common feature types:
|
|
|
|
```typescript
|
|
import { items } from "@tests/utils/fixtures/items.js";
|
|
```
|
|
|
|
| Item | Description | Default |
|
|
|------|-------------|---------|
|
|
| `items.dashboard()` | Boolean feature (on/off) | - |
|
|
| `items.monthlyMessages({ includedUsage })` | Resets monthly | 100 |
|
|
| `items.monthlyWords({ includedUsage })` | Resets monthly | 100 |
|
|
| `items.monthlyCredits({ includedUsage })` | Resets monthly | 100 |
|
|
| `items.unlimitedMessages()` | No usage cap | - |
|
|
| `items.lifetimeMessages({ includedUsage })` | Never resets | 100 |
|
|
| `items.prepaidMessages({ includedUsage })` | Buy upfront ($10/unit) | 0 |
|
|
| `items.consumableMessages({ includedUsage })` | Pay-per-use ($0.10/unit) | 0 |
|
|
| `items.allocatedUsers({ includedUsage })` | Prorated seats ($10/seat) | 0 |
|
|
|
|
### Product Fixtures (`@tests/utils/fixtures/products`)
|
|
|
|
```typescript
|
|
import { products } from "@tests/utils/fixtures/products.js";
|
|
```
|
|
|
|
| Product | Description |
|
|
|---------|-------------|
|
|
| `products.base({ items, id?, isDefault? })` | No base price. Defaults: `id="base"`, `isDefault=false` |
|
|
| `products.pro({ items, id? })` | **Includes $20/mo base price** - don't add `monthlyPrice()`. Default: `id="pro"` |
|
|
| `products.proAnnual({ items, id? })` | **Includes $200/yr base price**. Default: `id="pro-annual"` |
|
|
|
|
**Example:**
|
|
```typescript
|
|
// Free product (no price)
|
|
const free = products.base({ items: [items.monthlyMessages()] });
|
|
|
|
// Pro product - already has $20/mo, just add features
|
|
const pro = products.pro({ items: [items.monthlyMessages()] });
|
|
```
|
|
|
|
---
|
|
|
|
## Scenario Builder (`initScenario`) - Recommended
|
|
|
|
Use functional composition with `setup` and `actions` arrays for flexible test configuration:
|
|
|
|
```typescript
|
|
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
|
|
|
const { customerId, autumnV1, autumnV2, ctx, testClockId, entities } = await initScenario({
|
|
customerId: "my-test",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }), // testClock is true by default
|
|
s.products({ list: [pro, free] }),
|
|
s.entities({ count: 2, featureId: TestFeature.Users }), // optional
|
|
],
|
|
actions: [
|
|
s.attach({ productId: "pro", entityIndex: 0 }),
|
|
s.attach({ productId: "free", entityIndex: 1 }),
|
|
s.advanceTestClock({ days: 15 }), // optional
|
|
],
|
|
});
|
|
// entities[0].id = "ent-1", entities[1].id = "ent-2"
|
|
```
|
|
|
|
### Setup Methods (`s.*`)
|
|
|
|
| Method | Purpose |
|
|
|--------|---------|
|
|
| `s.customer({ paymentMethod?, data?, withDefault?, testClock? })` | Customer options. **`testClock` defaults to `true`** - don't pass it unless disabling |
|
|
| `s.products({ list })` | Products to create |
|
|
| `s.entities({ count, featureId })` | Auto-generate entities (ids: "ent-1", "ent-2", ...) |
|
|
|
|
> **Note:** `testClock` defaults to `true` - you don't need to pass `testClock: true` in most tests.
|
|
|
|
### Action Methods (`s.*`)
|
|
|
|
| Method | Purpose |
|
|
|--------|---------|
|
|
| `s.attach({ productId, entityIndex? })` | Attach product (omit entityIndex for customer-level) |
|
|
| `s.cancel({ productId, entityIndex? })` | Cancel product subscription |
|
|
| `s.advanceTestClock({ days?, weeks?, hours?, months?, toNextInvoice? })` | Advance test clock after attachments |
|
|
|
|
### Examples
|
|
|
|
**Simple test (no entities):**
|
|
```typescript
|
|
const { customerId, autumnV1 } = await initScenario({
|
|
customerId: "simple-test",
|
|
setup: [
|
|
s.customer({}), // testClock defaults to true
|
|
s.products({ list: [free] }),
|
|
],
|
|
actions: [s.attach({ productId: "base" })],
|
|
});
|
|
```
|
|
|
|
**With payment method:**
|
|
```typescript
|
|
const { customerId, autumnV1, ctx } = await initScenario({
|
|
customerId: "paid-test",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
```
|
|
|
|
**With entities:**
|
|
```typescript
|
|
const { customerId, autumnV1, entities } = await initScenario({
|
|
customerId: "entity-test",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro, free] }),
|
|
s.entities({ count: 2, featureId: TestFeature.Users }),
|
|
],
|
|
actions: [
|
|
s.attach({ productId: "pro", entityIndex: 0 }),
|
|
s.attach({ productId: "free", entityIndex: 1 }),
|
|
],
|
|
});
|
|
// entities[0].id = "ent-1", entities[1].id = "ent-2"
|
|
```
|
|
|
|
**With clock advancement:**
|
|
```typescript
|
|
const { customerId, autumnV1, advancedTo } = await initScenario({
|
|
customerId: "clock-test",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [
|
|
s.attach({ productId: "pro" }),
|
|
s.advanceTestClock({ days: 15 }),
|
|
],
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Test Clocks
|
|
|
|
**Critical:** `Date.now()` does NOT change when using `s.advanceTestClock`. Always use `advancedTo` from `initScenario`.
|
|
|
|
```typescript
|
|
const { advancedTo } = await initScenario({
|
|
actions: [
|
|
s.attach({ productId: pro.id }),
|
|
s.advanceTestClock({ days: 3 }),
|
|
],
|
|
});
|
|
|
|
// ❌ WRONG - Date.now() is still real time
|
|
expect(trialEndsAt).toBeCloseTo(Date.now() + ms.days(4));
|
|
|
|
// ✅ CORRECT - Use advancedTo (Stripe test clock's current time)
|
|
expect(trialEndsAt).toBeCloseTo(advancedTo + ms.days(4));
|
|
```
|
|
|
|
`advancedTo` is the Unix timestamp (ms) of the Stripe test clock after all `s.advanceTestClock` actions complete.
|
|
|
|
---
|
|
|
|
## Product ID in `s.attach()`
|
|
|
|
**Important:** Always use the product variable's `.id` property in `s.attach()`, never a string literal.
|
|
|
|
```typescript
|
|
const free = products.base({ items: [messagesItem] });
|
|
const pro = products.pro({ items: [messagesItem] });
|
|
|
|
// ✅ GOOD - Use product.id
|
|
actions: [
|
|
s.attach({ productId: free.id }),
|
|
s.attach({ productId: pro.id }),
|
|
]
|
|
|
|
// ❌ BAD - Don't use string literals
|
|
actions: [
|
|
s.attach({ productId: "base" }), // Wrong!
|
|
s.attach({ productId: "pro" }), // Wrong!
|
|
]
|
|
```
|
|
|
|
This ensures consistency and prevents bugs when product IDs change. The same `product.id` is used for both `s.attach()` and subsequent API calls.
|
|
|
|
---
|
|
|
|
## Prepaid Items
|
|
|
|
**Prepaid items require a `quantity` in `options`** when attaching or updating:
|
|
|
|
```typescript
|
|
const prepaidItem = items.prepaidMessages({
|
|
includedUsage: 0,
|
|
billingUnits: 100, // 1 pack = 100 units
|
|
price: 10, // $10 per pack
|
|
});
|
|
```
|
|
|
|
### Key Rules
|
|
|
|
1. **`quantity` is the total units you want** - NOT multiplied by billing units
|
|
2. **`quantity` is separate from `included_usage`** - included_usage provides free balance, quantity is purchased balance
|
|
3. **Balance = included_usage + quantity - usage**
|
|
|
|
### Attaching Prepaid Products
|
|
|
|
```typescript
|
|
// Attach with 200 units purchased (2 packs)
|
|
await initScenario({
|
|
actions: [
|
|
s.attach({
|
|
productId: "pro",
|
|
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
|
|
}),
|
|
],
|
|
});
|
|
```
|
|
|
|
### Updating Prepaid Quantities
|
|
|
|
```typescript
|
|
// Upgrade from 200 to 500 units
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
options: [{ feature_id: TestFeature.Messages, quantity: 500 }],
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
// preview.total = (5 packs - 2 packs) * $10 = $30
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
```
|
|
|
|
### Prepaid Billing Logic
|
|
|
|
On update, the system:
|
|
1. Refunds previous prepaid: `old_packs * old_price`
|
|
2. Charges new prepaid: `new_packs * new_price`
|
|
3. `preview.total = new_charge - old_refund`
|
|
|
|
```typescript
|
|
// Old: 2 packs * $10 = $20
|
|
// New: 5 packs * $10 = $50
|
|
// preview.total = $50 - $20 = $30 (charge)
|
|
expect(preview.total).toBe(30);
|
|
|
|
// Old: 5 packs * $10 = $50
|
|
// New: 2 packs * $10 = $20
|
|
// preview.total = $20 - $50 = -$30 (credit)
|
|
expect(preview.total).toBe(-30);
|
|
```
|
|
|
|
---
|
|
|
|
## Legacy: `initTestScenario`
|
|
|
|
For simpler cases without entities, `initTestScenario` is still available but `initScenario` is preferred:
|
|
|
|
```typescript
|
|
import { initTestScenario } from "@tests/utils/testInitUtils/initTestScenario.js";
|
|
|
|
const { customerId, autumnV1, ctx } = await initTestScenario({
|
|
customerId: "unique-test-id",
|
|
products: [free, addon],
|
|
attachProducts: [free.id], // Original IDs (auto-prefixed)
|
|
customerOptions: {
|
|
withTestClock: true,
|
|
attachPm: "success",
|
|
},
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Manual Setup (when initScenario doesn't fit)
|
|
|
|
### Customer Initialization
|
|
|
|
```typescript
|
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
|
|
|
await initCustomerV3({
|
|
ctx,
|
|
customerId,
|
|
customerData: { fingerprint: "test" },
|
|
withTestClock: true,
|
|
withDefault: true, // Attach default product on creation
|
|
attachPm: "success",
|
|
});
|
|
```
|
|
|
|
### Product Initialization (Direct)
|
|
|
|
```typescript
|
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
|
|
|
await initProductsV0({
|
|
ctx,
|
|
products: [free, pro],
|
|
prefix: customerId, // Prefix product IDs for isolation
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Running Tests
|
|
|
|
### Run specific test block
|
|
Place cursor inside a `test.concurrent()` block and press `Cmd+T`.
|
|
|
|
### Rerun last test
|
|
`Cmd+Shift+P` → "Rerun Last Task"
|
|
|
|
### Run entire file
|
|
```bash
|
|
bun test path/to/file.test.ts
|
|
```
|
|
|
|
---
|
|
|
|
## Code Style
|
|
|
|
### Avoid Parameter Duplication
|
|
|
|
When calling similar methods (like `previewUpdate` + `update`), define params once and reuse:
|
|
|
|
```typescript
|
|
// ❌ BAD - Duplicated params
|
|
const preview = await autumnV1.subscriptions.previewUpdate({
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [prepaidItem, priceItem],
|
|
options: [{ feature_id: TestFeature.Users, quantity: 10 }],
|
|
});
|
|
|
|
await autumnV1.subscriptions.update({
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [prepaidItem, priceItem],
|
|
options: [{ feature_id: TestFeature.Users, quantity: 10 }],
|
|
});
|
|
|
|
// ✅ GOOD - Define once, reuse
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [prepaidItem, priceItem],
|
|
options: [{ feature_id: TestFeature.Users, quantity: 10 }],
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
```
|