TESTING_STRATEGY — pricing-service
Sibling: DOMAIN_MODEL · APPLICATION_LOGIC · DATA_MODEL
The derivation pipeline is the single most important piece of code in the service: a wrong rule selection or a 1-cent rounding error compounds across thousands of quotes. We invest correspondingly: ≥ 90% line coverage and ≥ 95% branch coverage on the domain layer, with property-based tests on the rate-rules engine, FX rounding, and Sharia guard.
1. Test pyramid
┌────────────────┐
│ E2E (Playwright + reservation-svc) ~ 25 tests
├────────────────┤
│ Integration (DB + Pub/Sub emulators) ~ 180 tests
├────────────────┤
│ Use-case unit (with mocked ports) ~ 320 tests
├────────────────┤
│ Domain unit (pure) ~ 600 tests
├────────────────┤
│ Property-based (fast-check) ~ 40 properties × 200 runs
└────────────────┘
All tests run in CI on every PR; the property-based suite runs with 200 runs in PR mode and 5_000 runs in nightly mode.
2. Tooling
| Concern | Tool |
|---|---|
| Unit / use-case | vitest |
| Integration | vitest + @melmastoon/test-pg (Postgres in Docker) + @google-cloud/pubsub-emulator |
| Property-based | fast-check |
| HTTP integration | supertest against the running NestJS module |
| OpenAPI conformance | @apidevtools/swagger-parser + custom schema-roundtrip test |
| Contract tests (events) | @melmastoon/event-contracts (validates produced + consumed events against committed JSON Schema) |
| E2E | Playwright + docker-compose.dev.yml bringing up pricing + reservation + iam + booking BFF |
| Mutation testing | stryker-mutator (nightly, target ≥ 80% mutation score on domain layer) |
| Coverage | vitest --coverage (v8 provider); thresholds enforced in CI |
3. Domain unit tests (pure)
Each pure function in src/domain/services/*.ts has a dedicated .spec.ts. Examples:
deriveNightly.spec.ts— table-driven across BAR / weekly / non-refundable / package / corporate / government plans.selectRule.spec.ts— verifies tie-breaking by priority then by smaller scope (more specific wins).applyDiscounts.spec.ts— verifies discount stacking order: percent → flat → loyalty.composeFees.spec.tsandcomposeTaxes.spec.ts— verify inclusive/exclusive composition order with worked examples.applyFx.spec.ts— verifies per-currency rounding (e.g. IRR rounds to 1 000 micro = 1 toman).shariaGuard.spec.ts— verifies all forbidden combinations are rejected and all permitted ones pass.
Each .spec.ts includes at least one golden test: a hard-coded scenario whose expected output was hand-calculated and reviewed by Revenue Ops. Golden snapshots are committed and protected by a CODEOWNERS gate (@revenue-ops review required to change).
4. Property-based tests
Located in test/properties/. Each property runs fast-check over generated inputs.
| Property | Statement |
|---|---|
quote_total_is_sum_of_components | For any valid request, grandTotalMicro = subtotalMicro − discountMicro + feesMicro + taxesMicro exactly (no rounding gap > floor) |
derivation_is_deterministic | Running deriveNightly twice on the same (request, planSnapshot, rules, fees, taxes, fx) returns byte-identical output |
rule_selection_is_priority_monotonic | If two rules match a date+room and rule A has higher priority than B, A is selected; ties broken by scope specificity |
multiplier_no_negative | For multiplier in [0, 10], derived rate ≥ 0 |
discount_caps_at_zero | Even with stacked discounts > 100%, per-night net never goes below the per-currency rounding floor; never negative |
fx_rounding_safe | For any rate ∈ (0, 100], micro-conversion preserves sign and the absolute error per night is ≤ floor |
sharia_compliant_excludes_riba_fee | For any sharia-compliant plan with any fee set, the guard rejects iff at least one fee has shariaTag='riba_forbidden' |
promo_idempotent_per_quote | Redeeming the same promo with the same (promotion_id, quote_id) twice never increments usage_count more than once |
tax_rule_window_overlap_forbidden | Generated overlapping tax-rule pairs are rejected by the EXCLUDE constraint (integration test variant) |
Generators are biased to corner cases: 0, max int, negative-flirting numbers, leap-year date ranges, DST boundaries, IRR (huge nominal numbers).
5. Use-case tests
Each use case in src/application/use-cases/*.ts has a .spec.ts that:
- Spins up the use case with mocked ports (in-memory implementations of every repository + a fake clock + a fake id-generator).
- Exercises happy path, every documented error path, and the OCC mismatch path.
- Asserts that the expected outbox events were appended (subject + payload) inside the same UoW.
- Asserts idempotency: calling the use case twice with the same
Idempotency-Keyproduces the same response and the same outbox set.
Naming convention: should_<expected outcome>_when_<condition> (mocha-style); flaky-test detection uses vitest --reporter=dot --bail=0 over 50 reruns nightly.
6. Integration tests
Located in test/integration/. Each test:
- Boots Postgres (containerised, fresh schema per test file via Drizzle migrations).
- Boots the Pub/Sub emulator.
- Boots the NestJS app via
Test.createTestingModule(AppModule).compile(). - Exercises the real HTTP surface via
supertest. - Asserts both the HTTP response and the resulting database + outbox state.
Important integration scenarios:
- End-to-end quote: POST → derive → outbox row appears → publisher emits → Pub/Sub message lands → BigQuery sink (verified by emulator log).
- Quote lock/release with
reservation-service's test double. - Promo race: 50 parallel
:calculatecalls with the same promo where cap = 10; verify exactly 10 redemptions, exactly 40MELMASTOON.PRICING.PROMO_OVEROBLIGATIONrejections, no leak. - Tax rule overlap: insert overlapping rule, expect Postgres EXCLUDE violation, verify error mapped to
MELMASTOON.PRICING.RULE_OVERLAP. - FX provider down: stub FX provider to fail; verify cached snapshot is used; verify quote carries
fxSnapshot.stale=true; verify alert metric increment. - Cross-tenant isolation regression test: seed two tenants, run authenticated query as tenant A, assert zero rows of tenant B regardless of query shape.
- RLS bypass attempt: connect as
pricing_appwithoutSET LOCAL app.tenant_id, expect zero results from every table.
7. Contract tests (events)
pact-style event contracts:
- For each subject in EVENT_SCHEMAS we have a producer test that builds a sample payload from the use case and validates against the JSON Schema.
- For each consumed subject we have a fixture set under
test/fixtures/inbox/that the inbox handlers must process without error. - A contracts CI job runs against the latest published schemas in
@melmastoon/contracts; PRs that change a schema require an ADR and a coordinated bump in consumer services.
8. E2E tests
E2E tests live in the platform repo (tests/e2e/pricing/) and run nightly + on release-candidate tags. They cover the booking funnel:
- Guest searches
:5173/properties/<id>→ BFF callspricing-serviceto list rate plans. - Guest selects dates + room → BFF calls
pricing-service.POST /quotes→ guest sees price. - Guest applies promo →
:validatecall →:calculatererun → updated total. - Guest commits → reservation-service calls
:lock→ quote becomeslocked. - Cancellation → reservation-service calls
:release→ quote becomesexpired.
A separate suite covers the backoffice HITL flow:
- Operator triggers AI suggestion → AI orchestrator returns range.
- Operator accepts suggestion → new rate rule appears for tomorrow's date.
- Subsequent quote uses the new rule.
9. Test data
test/fixtures/seed/contains canonical fixtures: 4 rate plans (BAR, Weekly, Government, Non-Refundable), 2 properties, 3 currencies (USD/AFN/IRR), 2 promos.test/fixtures/inbox/contains canonical inbound event payloads matching the schemas in EVENT_SCHEMAS.- Fixtures are validated against schemas at test boot; broken fixtures fail fast.
10. CI gates
| Gate | Failure action |
|---|---|
lint (eslint, prettier) | block PR |
typecheck (tsc --noEmit) | block PR |
test:unit | block PR |
test:property (200 runs) | block PR |
test:integration | block PR |
test:contracts | block PR |
test:openapi-conformance | block PR |
coverage (≥ 85% lines, ≥ 90% domain, ≥ 95% domain branches) | block PR |
mutation-score (nightly, ≥ 80% on domain) | open Slack alert; block release tag |
e2e-nightly | open Slack alert; block release tag |
The release tag is gated by green CI on main for the prior 24 hours plus a manual approval per SERVICE_READINESS.
11. Performance and chaos tests
- k6 load test (
test/load/quote-load.js) targets 500 RPS sustained for 10 minutes; asserts p99 ≤ 250 ms and zero 5xx. - Chaos: weekly run of LitmusChaos experiments — kill a Cloud Run revision pod, sever Pub/Sub connection for 30 s, partition Redis. The
quote_latency_p99SLO must remain green. - DB failover drill: monthly Cloud SQL failover; the service must reconnect within 90 s with no data loss.
Performance regressions > 10% on the k6 baseline open an automatic GitHub issue with profiler output attached.