Skip to content

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) ​

#PropertyWhat It Proves
1Rate Resolution Correctnessresolved_rate = override.org_rate ?? Effective_Superadmin_Rate
13Effective Superadmin Rate ResolutionEffective_Rate = superadmin_org_rate ?? global cost_per_min
17Rate Snapshot Captures Effective RateSnapshot records correct source (per_org vs global)

Billing Math (Properties 8, 9, 11, 12) ​

#PropertyWhat It Proves
8Billing Split CorrectnesscustomerCharge = ceil(units * org_rate), orgCharge = ceil(units * superadmin_rate)
9Rate Snapshot ImmutabilityMid-call rate changes don't affect billing (snapshot isolation)
11Transaction Metadata CompletenessEvery transaction records all required audit fields
12Cumulative Profit TrackingtotalProfitRetained = Σ(customerCharge_i - orgCharge_i)

Cascade & Enforcement (Properties 2, 3, 4, 5, 6, 7) ​

#PropertyWhat It Proves
2Superadmin Rate Update Persistencecost_per_min update persists + version increments by exactly 1
3OrgConfigReplica Schema ConstraintReplica contains only expected fields (no leakage)
4Cascading Floor EnforcementAfter global rate increase, all org_rates >= new rate (for non-per-org orgs)
5Preset Deletion Cascade CleanupDeleted presets removed from all replicas
6New Preset Default VisibilityNew presets visible to all orgs by default
7Minimum Pricing ValidationRejects org_rate < Effective_Superadmin_Rate

Per-Org Rate (Properties 14, 15, 16) ​

#PropertyWhat It Proves
14Superadmin Org Rate PersistenceSet → read round-trip returns exact value
15Superadmin Org Rate Floor EnforcementAfter setting per-org rate, org_rate >= superadmin_org_rate
16Global Cascade IsolationOrgs 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:

typescript
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:

typescript
// 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:

typescript
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 ​

bash
# 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.ts

The 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) ​

#PropertyFileWhat It Proves
1Floor Constraint Enforcementservice-pricing-floor-constraint.test.tsupsert succeeds ⟺ customer_rate >= org_rate
8Positive Rate Validationservice-pricing-data-model.test.tsSchema rejects any customer_rate <= 0

Cascade Logic (Properties 3, 4) ​

#PropertyFileWhat It Proves
3Cascade Floor Invariantservice-pricing-cascade-floor.test.tsAfter org_rate increase, ALL customer rates >= new org_rate
4Visibility Cascade Deletionservice-pricing-visibility-cascade.test.tsWhen preset goes invisible, zero overrides remain for that org+preset

Rate Resolution (Properties 2, 9) ​

#PropertyFileWhat It Proves
2Rate Resolution Hierarchyrate-resolution-hierarchy.test.tsresolved = customer_override → org_override → default
9Invisible Preset Rate Resolutioninvisible-preset-rate-resolution.test.tsInvisible presets NEVER resolve to customer_override

Access Control & Filtering (Properties 5, 6, 7) ​

#PropertyFileWhat It Proves
5Visibility Filteringservice-pricing-visibility-filtering.test.tsAPI only returns presets where visible=true; rejects upserts on invisible
6Upsert Idempotencyservice-pricing-data-model.test.tsMultiple upserts on same key → exactly one document
7Organisation Boundary Enforcementservice-pricing-org-boundary.test.tsCustomer from different org → 403 on upsert AND delete

Frontend (Property 10) ​

#PropertyFileWhat It Proves
10Client-Side Floor Validationapp/src/pages/admin/__tests__/ServicePricingPage.floorValidation.property.test.tsSubmit 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

Last updated: