Property-Based Tests Credit System & Service Pricing
What Is Property-Based Testing?
Instead of writing tests with hardcoded inputs (rate = 5, units = 3, expect charge = 15), property-based tests generate random inputs and verify universal invariants hold for all of them.
Example: "For ANY org_rate and units, customerCharge MUST equal Math.ceil(units * org_rate)."
If fast-check finds a single counterexample in 50+ random runs, the test fails and reports the exact input that broke the invariant. This catches edge cases you'd never think to test manually (floating-point rounding, zero values, very large numbers, etc.).
Framework
- Test runner: Vitest
- Property generator: fast-check v4
- Database: MongoMemoryServer / MongoMemoryReplSet (in-memory, no external DB needed)
Why We Use Property Tests for the Credit System
The credit system handles real money. Bugs here mean:
- Customers get overcharged or undercharged
- Organizations lose revenue silently
- Platform loses money on preferential pricing
- Rate changes mid-call corrupt billing
Property tests give us mathematical proof that core invariants hold across the entire input space not just the 5-10 examples a developer thought to write.
The 17 Correctness Properties
Rate Resolution (Properties 1, 13, 17)
| # | Property | What It Proves |
|---|---|---|
| 1 | Rate Resolution Correctness | resolved_rate = override.org_rate ?? Effective_Superadmin_Rate |
| 13 | Effective Superadmin Rate Resolution | Effective_Rate = superadmin_org_rate ?? global cost_per_min |
| 17 | Rate Snapshot Captures Effective Rate | Snapshot records correct source (per_org vs global) |
Billing Math (Properties 8, 9, 11, 12)
| # | Property | What It Proves |
|---|---|---|
| 8 | Billing Split Correctness | customerCharge = ceil(units * org_rate), orgCharge = ceil(units * superadmin_rate) |
| 9 | Rate Snapshot Immutability | Mid-call rate changes don't affect billing (snapshot isolation) |
| 11 | Transaction Metadata Completeness | Every transaction records all required audit fields |
| 12 | Cumulative Profit Tracking | totalProfitRetained = Σ(customerCharge_i - orgCharge_i) |
Cascade & Enforcement (Properties 2, 3, 4, 5, 6, 7)
| # | Property | What It Proves |
|---|---|---|
| 2 | Superadmin Rate Update Persistence | cost_per_min update persists + version increments by exactly 1 |
| 3 | OrgConfigReplica Schema Constraint | Replica contains only expected fields (no leakage) |
| 4 | Cascading Floor Enforcement | After global rate increase, all org_rates >= new rate (for non-per-org orgs) |
| 5 | Preset Deletion Cascade Cleanup | Deleted presets removed from all replicas |
| 6 | New Preset Default Visibility | New presets visible to all orgs by default |
| 7 | Minimum Pricing Validation | Rejects org_rate < Effective_Superadmin_Rate |
Per-Org Rate (Properties 14, 15, 16)
| # | Property | What It Proves |
|---|---|---|
| 14 | Superadmin Org Rate Persistence | Set → read round-trip returns exact value |
| 15 | Superadmin Org Rate Floor Enforcement | After setting per-org rate, org_rate >= superadmin_org_rate |
| 16 | Global Cascade Isolation | Orgs with per-org rates are NOT affected by global cascade |
How They Work (Technical)
MongoMemoryReplSet
Most property tests need MongoDB transactions (used by consumeCredits). Transactions require a replica set. We use mongodb-memory-server's MongoMemoryReplSet to spin up an in-memory replica:
let replSet: MongoMemoryReplSet;
beforeAll(async () => {
replSet = await MongoMemoryReplSet.create({ replSet: { count: 1 } });
await mongoose.connect(replSet.getUri());
}, 60000); // 60s timeout for replica initialization
afterAll(async () => {
await mongoose.disconnect();
await replSet.stop();
});fast-check Generators
Each test defines custom generators for valid inputs:
// Generates a rate between 0.01 and 500
const rateArb = fc.double({ min: 0.01, max: 500, noNaN: true, noDefaultInfinity: true });
// Generates call duration between 0.1 and 120 minutes
const unitsArb = fc.double({ min: 0.1, max: 120, noNaN: true, noDefaultInfinity: true });
// Generates a valid preset ID
const presetIdArb = fc.string({ minLength: 3, maxLength: 12, unit: fc.constantFrom(..."abc...") })
.map(s => `preset_${s}`);Cleanup Between Iterations
Each fast-check iteration runs independently state is cleaned between runs:
await fc.assert(
fc.asyncProperty(rateArb, unitsArb, async (rate, units) => {
// Clean state for this iteration
await Organization.deleteMany({});
await User.deleteMany({});
await CreditTransaction.deleteMany({});
// ... test logic ...
}),
{ numRuns: 50 }
);Running Only Property Tests
# All property tests (credit system + service pricing)
npx vitest --run --testPathPattern="(property|correctness|persistence|immutability|completeness|tracking|cascade|schema-constraint|effective-superadmin|service-pricing|rate-resolution-hierarchy|invisible-preset)"
# Credit system property tests only
npx vitest --run --testPathPattern="(billing-split|cumulative-profit|rate-snapshot|transaction-metadata|global-cascade|schema-constraint|effective-superadmin)"
# Service pricing property tests only
npx vitest --run --testPathPattern="service-pricing|rate-resolution-hierarchy|invisible-preset"
# Single property test
npx vitest --run src/__tests__/billing-split-correctness.test.tsThe 10 Service Pricing Correctness Properties
These properties validate the customer-specific pricing override system. They ensure rate floors, cascade logic, visibility filtering, and access control behave correctly across all input combinations.
Floor & Validation (Properties 1, 8)
| # | Property | File | What It Proves |
|---|---|---|---|
| 1 | Floor Constraint Enforcement | service-pricing-floor-constraint.test.ts | upsert succeeds ⟺ customer_rate >= org_rate |
| 8 | Positive Rate Validation | service-pricing-data-model.test.ts | Schema rejects any customer_rate <= 0 |
Cascade Logic (Properties 3, 4)
| # | Property | File | What It Proves |
|---|---|---|---|
| 3 | Cascade Floor Invariant | service-pricing-cascade-floor.test.ts | After org_rate increase, ALL customer rates >= new org_rate |
| 4 | Visibility Cascade Deletion | service-pricing-visibility-cascade.test.ts | When preset goes invisible, zero overrides remain for that org+preset |
Rate Resolution (Properties 2, 9)
| # | Property | File | What It Proves |
|---|---|---|---|
| 2 | Rate Resolution Hierarchy | rate-resolution-hierarchy.test.ts | resolved = customer_override → org_override → default |
| 9 | Invisible Preset Rate Resolution | invisible-preset-rate-resolution.test.ts | Invisible presets NEVER resolve to customer_override |
Access Control & Filtering (Properties 5, 6, 7)
| # | Property | File | What It Proves |
|---|---|---|---|
| 5 | Visibility Filtering | service-pricing-visibility-filtering.test.ts | API only returns presets where visible=true; rejects upserts on invisible |
| 6 | Upsert Idempotency | service-pricing-data-model.test.ts | Multiple upserts on same key → exactly one document |
| 7 | Organisation Boundary Enforcement | service-pricing-org-boundary.test.ts | Customer from different org → 403 on upsert AND delete |
Frontend (Property 10)
| # | Property | File | What It Proves |
|---|---|---|---|
| 10 | Client-Side Floor Validation | app/src/pages/admin/__tests__/ServicePricingPage.floorValidation.property.test.ts | Submit disabled ⟺ input < org_rate |
When to Add a New Property Test
Add one when:
- You're building logic that must be universally correct (billing, rate calculation, access control)
- The logic has mathematical invariants (A always equals B * C, X >= Y)
- There are many possible inputs and edge cases matter (floating point, zero, very large numbers)
- A bug in this code means financial loss or data corruption
Don't add one for:
- Simple CRUD operations
- UI rendering logic
- One-time migration scripts
- Configuration/setup code