TESTING_STRATEGY — reservation-service
Sibling: DOMAIN_MODEL · APPLICATION_LOGIC · API_CONTRACTS · EVENT_SCHEMAS
Strategic anchors: 02 §15 Testing · standards/SERVICE_TEMPLATE · standards/DEFINITION_OF_DONE
The booking saga is the most consequential multi-service flow on the platform. Testing for reservation-service is shaped by three forces: domain correctness (state machine + invariants), saga correctness (forward + every compensation), and offline-correctness (desktop-replicated subset never diverges). Coverage targets follow the platform DOD — overall ≥ 85%, domain layer ≥ 95% — but this service additionally must ship the per-saga compensation matrix and the FX-snapshot stability test before production.
1. Test pyramid
| Layer | Framework | Targets | Where |
|---|---|---|---|
| Unit (domain) | Vitest | ≥ 95% statements; 100% branches on state machine | src/domain/__tests__/, test/unit/domain/ |
| Unit (application) | Vitest | ≥ 90% statements per use case | test/unit/application/ |
| Integration (in-service) | Vitest + Testcontainers (Postgres + Pub/Sub emulator) | ≥ 80% paths | test/integration/ |
| Contract (consumer) | Pact (broker) | every produced + consumed event has a contract | test/contract/ |
| API contract | OpenAPI snapshot diff + dredd | every endpoint | CI gate |
| End-to-end | Playwright via bff-backoffice-service and bff-tenant-booking-service | top 5 user journeys | test/e2e/ |
| Chaos / low-bandwidth | k6 + custom toxiproxy harness | latency, packet loss, disconnect | test/chaos/ |
The three mandatory integration tests (tenant-isolation.spec.ts, outbox.spec.ts, inbox.spec.ts) gate the readiness checklist and must be present before any feature work — see SERVICE_TEMPLATE §Mandatory tests.
2. Unit — domain
| Spec | Coverage |
|---|---|
reservation.state-machine.spec.ts | every legal transition (table-driven from DOMAIN_MODEL §3.1); every illegal transition rejected with ILLEGAL_TRANSITION |
reservation.invariants.spec.ts | one test per invariant I1..I14 (pass + fail) |
cancellation-policy-evaluator.spec.ts | boundary tests per policy snapshot version (e.g., tenant.cancellation_policy@v1, @v2, @v3); refund/penalty math; OCC-safe |
overstay-detector.spec.ts | property time-zone (Asia/Kabul, Asia/Tehran, Asia/Dushanbe) + DST transitions |
modification-differ.spec.ts | symmetric, minimal diffs; nested arrays in items and additionalGuests; ordered tag arrays do not produce churn |
stay-window-overlap-detector.spec.ts | 1-night, 30-night, contiguous, overlapping, exclusive-end semantics |
fx-snapshot.spec.ts | immutability post-confirm; rate precision; IRR scale (large numbers) |
reservation.code-generator.spec.ts | deterministic, collision behavior under simulated duplicates |
occupancy-validator.spec.ts | I9 ±1 tolerance; child-without-id flagged but allowed |
The domain test suite imports nothing outside @ghasi/domain-primitives and Vitest. CI runs lint:layer-boundaries to enforce.
3. Unit — application
| Spec | What it asserts |
|---|---|
request-quote.use-case.spec.ts | calls PricingClient, emits quote.created.v1, idempotent on Idempotency-Key |
hold-reservation.use-case.spec.ts | quote-expiry rejection; inventory hold success; payment intent created; held.v1 outboxed |
confirm-reservation.use-case.spec.ts | event-driven; replays are no-ops; FX snapshot locked |
cancel-reservation.use-case.spec.ts | policy evaluation; OCC; refund/penalty; cancelled.v1 outboxed |
modify-reservation.*.use-case.spec.ts | one spec per sub-type; sub-saga step ordering; failure handling |
check-in.use-case.spec.ts + check-out.use-case.spec.ts | early/normal; folio open/close request; lock client called; requiresManualKey path |
walk-in.use-case.spec.ts | combined create+check-in; deferKyc flag; idempotency per operator session |
merge-reservations.use-case.spec.ts, split-group.use-case.spec.ts | guards and audit rows |
record-no-show.use-case.spec.ts, record-early-checkout.use-case.spec.ts | policy applied; events emitted |
add-special-request.use-case.spec.ts | AI parser fallback; provenance persisted |
expire-holds.use-case.spec.ts | batch behavior; idempotency on already-expired aggregates |
Mocks for ports use the in-memory fakes shipped under __fakes__/ (no Sinon/Mockito-style spies for use-case logic — fakes assert behavior).
4. Integration — in-service
Run against ephemeral Postgres + Pub/Sub emulator under Testcontainers. Each test spins up a clean database via Drizzle migrations.
4.1 Mandatory three (gate the readiness checklist)
test/integration/tenant-isolation.spec.ts— proves cross-tenant reads return404, cross-tenant writes raise403/MELMASTOON.TENANT.MISMATCH, RLS holds even with raw SQL bypass attempt.test/integration/outbox.spec.ts— proves aggregate save + outbox row are atomic; relay publishes at-least-once; mid-batch crash leaves no event lost or double-committed.test/integration/inbox.spec.ts— proves consumed events deduped byevent_id; idempotent on retry; consumer crash mid-handle resumes correctly.
4.2 Booking saga happy path + 8 compensation paths (this service must own these)
Forward and every named compensation. Each test plants events into the Pub/Sub emulator and asserts the resulting aggregate state, audit, and emitted events.
| # | Scenario | Asserted state |
|---|---|---|
| H1 | Forward path: hold → inventory.committed → payment.captured → confirmed → key.issued (today) | confirmed, keyCredentialIds.length=1, no manual key |
| C1 | inventory.allocation.failed.v1 | reservation cancelled with reasonCode=inventory_failed; pending payment intent cancelled |
| C2 | payment.transaction.failed.v1 | cancelled/reasonCode=payment_failed; inventory release event emitted |
| C3 | Hold TTL elapsed without confirm | expired_hold; inventory release; payment intent cancellation |
| C4 | Confirm-then-cancel during 24h policy window | cancelled, refund event for full amount, key revoke event emitted (no key yet so no-op) |
| C5 | Confirm-then-cancel beyond policy window | cancelled, partial refund per tenant.cancellation_policy@vN, audit row with policy ref |
| C6 | lock.key.failed.v1 at check-in | reservation transitions to checked_in with requiresManualKey=true; alert event emitted |
| C7 | Date-change sub-saga: payment delta charge fails | inventory reallocation reverted; reservation unchanged; MELMASTOON.PAYMENT.CHARGE_FAILED returned |
| C8 | Operator modifies reservation while saga in flight (pendingSagaStep != null) | command rejected with MELMASTOON.RESERVATION.SAGA_IN_FLIGHT |
4.3 Concurrency
test/integration/concurrency-check-in.spec.ts— two concurrent check-in attempts; one succeeds, the other receivesSTALE_VERSION; no double key issuance.test/integration/concurrency-cancel-vs-modify.spec.ts— cancel and date-change submitted simultaneously; one wins; modification audit reflects only the winner.test/integration/concurrency-group-partial-cancel.spec.ts— three items, two operators each cancelling one; both succeed; final cancellation across all items flips reservation tocancelled.test/integration/hold-expiry-race.spec.ts— payment.captured arrives at the same instant as hold-expiry sweeper; outcome must be deterministic (sweeper checkspayment_statusandpending_saga_stepand skips).
4.4 Date-arithmetic edge cases
- DST transitions in
Asia/Tehran,Asia/Kabul(no DST since 2022 but historical data may exist),Asia/Dushanbe. - Year-boundary stays (
2026-12-30..2027-01-02). - Single-night vs zero-night attempts (zero rejected with
INVALID_STAY_WINDOW). - Long stays (90+ nights — extended-stay tenants).
- Property local-date conversions: a stay starting on
2026-04-25for a property at +03:30 should not be misclassified as starting2026-04-24UTC.
4.5 FX snapshot stability
test/integration/fx-snapshot-stability.spec.ts: confirm a USD-priced reservation for an IRR tenant. Subsequently mutate the tenant'spricing-serviceUSD/IRR rate. Re-read the reservation; assert the persistedfxSnapshotis unchanged. Modify the reservation (special_request_added); assert FX snapshot still unchanged. Attempt direct overwrite via raw SQL outside the aggregate factory; assert the next aggregate save raisesMELMASTOON.RESERVATION.FX_SNAPSHOT_LOCKED.- IRR magnitude test: confirm a 2-night stay totalling 23,478,000,000,000 micro-IRR; assert no overflow in
bigintserialization or BigQuery sink.
4.6 Cash-on-arrival flow
test/integration/cash-on-arrival.spec.ts: hold → confirm withpaymentMethod=cash_on_arrival(no captured payment yet) → assertpayment.status=pending_cash, reservation inconfirmed, no payment intent capture event consumed yet → simulate cash event viabilling-service→ reservation unchanged in state,payment.statusadvances tocaptured.
5. Contract tests
- Pact (consumer): for every event produced (
reservation.*), a Pact contract undertest/contract/describes the schema; consumers (inventory-service,billing-service,lock-integration-service,notification-service,analytics-service,audit-service,search-aggregation-service) verify against the broker. - Pact (provider): for every event consumed (
payment.*,inventory.*,lock.*,tenant.*,property.*), a contract verifies our handlers behave per shared expectations. - OpenAPI: the controller-derived
openapi.jsonis diffed against the previous release; breaking changes block the PR (must ship/api/v2). - Event schema registry: every produced subject is validated against the JSON Schema in EVENT_SCHEMAS; CI fails on drift.
6. End-to-end
Top user journeys, run nightly via Playwright in a staging environment with seeded data:
- Direct booking, card — funnel from quote to confirm to email arrival.
- Walk-in cash-on-arrival — front-desk operator captures + checks in within 90 s.
- Modify dates with delta charge — confirm + extend by 1 night + new key validity in TTLock simulator.
- Cancel with partial refund — policy-applied refund visible on folio + cancellation email.
- Group partial cancel — one item of three cancelled; reservation remains
confirmed.
Each journey asserts both the BFF response and the resulting events on the Pub/Sub emulator.
7. Chaos / low-bandwidth
- Toxiproxy between
bff-backoffice-serviceandreservation-service: introduce 800 ms latency, 5% packet loss, periodic 30-second blackouts. Walk-in flow must complete or queue without data loss. - Pub/Sub emulator restart mid-batch: outbox relay must resume without double-publish or loss.
- Postgres failover drill: read traffic rerouted during 30 s primary unavailability; writes return 503 with retry hint; OCC re-checks succeed on resume.
- Lock vendor outage simulation:
lock-integration-servicereturns 503 for 10 minutes; check-ins complete withrequiresManualKey=true; alarm RESV-007 fires.
8. Performance targets (load tests)
- 200 concurrent quote requests / 100 concurrent holds per tenant: p99 latency stays under 1.5 s (quote) / 2.5 s (hold).
- 1,000 reservations/day per large tenant: outbox lag stays < 30 s p99.
- Hold-expiry sweeper handles a 10,000-stale-hold backlog in a single 60-s pass without timeout.
k6 scripts live under test/perf/ and are run weekly in staging.
9. Test data builders
Builders are colocated under src/domain/__builders__/ and test/__builders__/:
aReservation()with state, items, channel, fxSnapshot helpers (e.g.,.held(),.confirmed(),.checkedIn()).aGuest()with locale, name script, document presets.anEvent()envelope builder;aPubSubMessage()adapter.aTenant()with policy snapshots andholdTtlSeconds.
Builders are pure-TS, framework-free, and produce types that satisfy the strict domain interfaces.
10. Cross-references
- Mandatory three integration tests: SERVICE_TEMPLATE §Mandatory tests
- Coverage thresholds: DEFINITION_OF_DONE
- Failure runbooks (used by chaos drills): FAILURE_MODES