Skip to main content

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

ConcernTool
Unit / use-casevitest
Integrationvitest + @melmastoon/test-pg (Postgres in Docker) + @google-cloud/pubsub-emulator
Property-basedfast-check
HTTP integrationsupertest 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)
E2EPlaywright + docker-compose.dev.yml bringing up pricing + reservation + iam + booking BFF
Mutation testingstryker-mutator (nightly, target ≥ 80% mutation score on domain layer)
Coveragevitest --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.ts and composeTaxes.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.

PropertyStatement
quote_total_is_sum_of_componentsFor any valid request, grandTotalMicro = subtotalMicro − discountMicro + feesMicro + taxesMicro exactly (no rounding gap > floor)
derivation_is_deterministicRunning deriveNightly twice on the same (request, planSnapshot, rules, fees, taxes, fx) returns byte-identical output
rule_selection_is_priority_monotonicIf two rules match a date+room and rule A has higher priority than B, A is selected; ties broken by scope specificity
multiplier_no_negativeFor multiplier in [0, 10], derived rate ≥ 0
discount_caps_at_zeroEven with stacked discounts > 100%, per-night net never goes below the per-currency rounding floor; never negative
fx_rounding_safeFor any rate ∈ (0, 100], micro-conversion preserves sign and the absolute error per night is ≤ floor
sharia_compliant_excludes_riba_feeFor any sharia-compliant plan with any fee set, the guard rejects iff at least one fee has shariaTag='riba_forbidden'
promo_idempotent_per_quoteRedeeming the same promo with the same (promotion_id, quote_id) twice never increments usage_count more than once
tax_rule_window_overlap_forbiddenGenerated 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:

  1. Spins up the use case with mocked ports (in-memory implementations of every repository + a fake clock + a fake id-generator).
  2. Exercises happy path, every documented error path, and the OCC mismatch path.
  3. Asserts that the expected outbox events were appended (subject + payload) inside the same UoW.
  4. Asserts idempotency: calling the use case twice with the same Idempotency-Key produces 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:

  1. Boots Postgres (containerised, fresh schema per test file via Drizzle migrations).
  2. Boots the Pub/Sub emulator.
  3. Boots the NestJS app via Test.createTestingModule(AppModule).compile().
  4. Exercises the real HTTP surface via supertest.
  5. 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 :calculate calls with the same promo where cap = 10; verify exactly 10 redemptions, exactly 40 MELMASTOON.PRICING.PROMO_OVEROBLIGATION rejections, 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_app without SET 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:

  1. Guest searches :5173/properties/<id> → BFF calls pricing-service to list rate plans.
  2. Guest selects dates + room → BFF calls pricing-service.POST /quotes → guest sees price.
  3. Guest applies promo → :validate call → :calculate rerun → updated total.
  4. Guest commits → reservation-service calls :lock → quote becomes locked.
  5. Cancellation → reservation-service calls :release → quote becomes expired.

A separate suite covers the backoffice HITL flow:

  1. Operator triggers AI suggestion → AI orchestrator returns range.
  2. Operator accepts suggestion → new rate rule appears for tomorrow's date.
  3. 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

GateFailure action
lint (eslint, prettier)block PR
typecheck (tsc --noEmit)block PR
test:unitblock PR
test:property (200 runs)block PR
test:integrationblock PR
test:contractsblock PR
test:openapi-conformanceblock PR
coverage (≥ 85% lines, ≥ 90% domain, ≥ 95% domain branches)block PR
mutation-score (nightly, ≥ 80% on domain)open Slack alert; block release tag
e2e-nightlyopen 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_p99 SLO 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.