TESTING_STRATEGY — inventory-service
Sibling: DOMAIN_MODEL · APPLICATION_LOGIC · API_CONTRACTS · EVENT_SCHEMAS · FAILURE_MODES
Strategic anchors: 02 §15 Testing · standards/SERVICE_TEMPLATE · standards/DEFINITION_OF_DONE
inventory-service is the only service on the platform with a zero-tolerance correctness target: never publish a false-overbooking event, never lose a held allocation, never cross-pollinate tenants. Testing is shaped by that target. The test pyramid is wider than the platform default in two layers: concurrency tests (lots of them) and a Jepsen-style consistency check that runs nightly in staging and at every release-candidate.
1. Test pyramid
| Layer | Framework | Targets | Where |
|---|---|---|---|
| Unit (domain) | Vitest | ≥ 95% statements; 100% branches on RoomAllocation state machine | src/domain/__tests__/ |
| Unit (application) | Vitest | ≥ 90% statements per use case | test/unit/application/ |
| Integration (in-service) | Vitest + Testcontainers (Postgres + Pub/Sub emulator) | ≥ 80% paths | test/integration/ |
| Concurrency | Vitest + Postgres + worker_threads | every advisory-lock + EXCLUDE-constraint path | test/concurrency/ |
| Jepsen-style consistency | custom harness on Testcontainers | "no false overbooking" property under partition + clock skew | test/jepsen/ |
| 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 | walk-in + block + reaccommodation + group hold | test/e2e/ |
| Chaos / load | k6 + toxiproxy | latency, packet loss, disconnect | test/chaos/, test/perf/ |
The mandatory three integration tests (tenant-isolation.spec.ts, outbox.spec.ts, inbox.spec.ts) gate the readiness checklist and ship before any feature work.
2. Unit — domain
| Spec | Coverage |
|---|---|
room-allocation.state-machine.spec.ts | every legal transition; every illegal transition rejected with IllegalAllocationTransition |
room-type-inventory.invariants.spec.ts | I2 (available ≥ 0), I10 (stop_sell), bed-vs-room consistency, oversell respect for overbooking_cap |
room-allocation.invariants.spec.ts | I3 (no overlap on roomId), I4 (held requires heldUntil > now), I5 (committed requires committedAt), I6 (released immutable), I9 (bed requires bedTotal) |
inventory-block.invariants.spec.ts | window validity, target (room or type), source taxonomy |
overbooking-policy.invariants.spec.ts | I13 (cap ≥ 0; routes if enabled) |
room-picker.spec.ts | property-based: any candidate set yields a deterministic pick per strategy; housekeeping_ready_first prefers ready rooms but never deadlocks |
overbooking-decider.spec.ts | allow / allow_with_alert / reject thresholds across cap values; off-policy returns reject |
reaccommodation-finder.spec.ts | block window vs allocation overlap math: contiguous, fully covered, partial overlap, exclusive-end |
group-atomic-hold-planner.spec.ts | canonical lock order is total over (property, room_type, date); avoids deadlock under shuffled inputs |
calendar-horizon-extender.spec.ts | inserts only forward; idempotent on re-run |
stay-window-night-iteration.spec.ts | nights[] length = checkOut - checkIn; DST transitions in Asia/Kabul/Asia/Tehran/Asia/Dushanbe; year boundary |
Domain tests import nothing outside @ghasi/domain-primitives and Vitest.
3. Unit — application
| Spec | What it asserts |
|---|---|
place-hold-allocation.use-case.spec.ts | lock acquired in canonical order; counter mutations + insert + outbox in one transaction; allocation.failed.v1 on rejection |
commit-allocation.use-case.spec.ts | flips held → committed; idempotent on replay; OCC respected |
release-allocation.use-case.spec.ts | per reasonCode path; counter delta correct; idempotent |
create-inventory-block.use-case.spec.ts | ReaccommodationFinder runs; reaccommodation_required.v1 emitted when overlaps; counters updated |
release-inventory-block.use-case.spec.ts | counters restored; block.released.v1 emitted |
group-atomic-hold.use-case.spec.ts | all-or-none; partial-fail rolls back; deadlock-free under stress |
walk-in-allocate.use-case.spec.ts | sync REST path; commits directly; idempotent on Idempotency-Key |
update-overbooking-policy.use-case.spec.ts | role check; OCC; audit emitted |
extend-calendar-horizon.use-case.spec.ts | inserts missing rows; respects per-property room-type catalog |
extend-new-room-calendar.use-case.spec.ts | bumps total on future dates only |
expire-holds.use-case.spec.ts | batch behavior; FOR UPDATE SKIP LOCKED; idempotent on already-released rows |
reconcile-calendar-summary.use-case.spec.ts | recomputes summary; alerts on drift |
Mocks for ports use the in-memory fakes shipped under __fakes__/.
4. Integration — in-service
Run against ephemeral Postgres + Pub/Sub emulator under Testcontainers. Each test spins a clean DB 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; advisory-lock keys cannot collide across tenants.test/integration/outbox.spec.ts— atomic aggregate save + outbox row; relay publishes at-least-once; mid-batch crash leaves no event lost or double-committed.test/integration/inbox.spec.ts— consumed events deduped byevent_id; idempotent on retry; consumer crash mid-handle resumes correctly.
4.2 Saga-side integration
| # | Scenario | Asserted state |
|---|---|---|
| S1 | reservation.held.v1 arrives, allocation succeeds | room_allocations row status='held', counters bumped, allocation.confirmed.v1 outboxed |
| S2 | reservation.held.v1 arrives, no availability | no row, allocation.failed.v1 outboxed with reasonCode='insufficient_availability' |
| S3 | reservation.confirmed.v1 arrives | row flips to committed, committedAt set, second allocation.confirmed.v1 outboxed |
| S4 | reservation.cancelled.v1 arrives post-confirm | row flips to released, counters decremented, allocation.released.v1 outboxed with reasonCode='reservation_cancelled' |
| S5 | reservation.dates_changed.v1 arrives | atomic release-then-allocate; if new fails, old retained, allocation.failed.v1 for the new attempt only |
| S6 | reservation.no_show.v1 arrives, tenant policy release_after_grace=true | release with reasonCode='reservation_no_show'; if policy false, no-op |
| S7 | reservation.hold_expired.v1 arrives | release with reasonCode='hold_expired' |
| S8 | property.room.taken_out_of_order.v1 arrives, overlapping committed allocations exist | block created, reaccommodation_required.v1 outboxed; allocations untouched |
| S9 | property.room.created.v1 arrives | future room_type_inventory_daily.total bumped for the room's type |
| S10 | Out-of-order: confirmed arrives before held | inbox handler returns RESERVATION_NOT_FOUND_FOR_ALLOCATION, message NACK'd; redelivery resolves |
4.3 Concurrency suite
This is the biggest investment. Tests run in worker_threads driving real Postgres.
test/concurrency/double-allocation-race.spec.ts— N=100 concurrentPlaceHoldAllocationUseCasefor the same single available room; exactly one wins, all others getINSUFFICIENT_AVAILABILITY; assertroom_allocationshas exactly one held row; assert no overlap-constraint violation.test/concurrency/group-hold-deadlock.spec.ts— many concurrent group holds across overlapping room types and nights, in randomized order; no deadlock; eventual consistency in counters.test/concurrency/cancel-vs-confirm-race.spec.ts—cancelledandconfirmedevents arrive within 1 ms; deterministic outcome via inbox ordering and OCC.test/concurrency/walk-in-vs-saga-race.spec.ts— operator walk-in andreservation.held.v1arrive simultaneously for the last available room of a type; only one succeeds.test/concurrency/sweeper-vs-confirm-race.spec.ts— sweeper runs whilereservation.confirmed.v1arrives for the soon-to-expire hold; sweeper must skip ifcommittedAtis set orstatusadvanced;confirmedwins.test/concurrency/block-vs-allocation-race.spec.ts—CreateInventoryBlockUseCaseandPlaceHoldAllocationUseCasefor the same room/night arrive concurrently; one wins under serializable invariant; the other receivesINSUFFICIENT_AVAILABILITY(allocation lost) or block created with reaccommodation (block won earlier).test/concurrency/dates-changed-vs-cancelled.spec.ts— both sub-sagas in flight; OCC resolves; no negative counters.
4.4 Date-arithmetic edge cases
- DST transitions in
Asia/Tehran(historical),Asia/Kabul,Asia/Dushanbe. - Year-boundary stays (
2026-12-30..2027-01-02). - 1-night and 90-night stays.
- Property local-date conversions (no UTC drift).
- Persian calendar display does not affect server arithmetic.
4.5 Partition rotation
test/integration/partition-rotation.spec.ts— runs therotate-partitionsjob against a clock-controlled Postgres; verifies new partitions appear, old partitions detach and export, default partition does not accumulate writes.- Failure path: partition creation fails → alert RESV-INV-009 fires; job retries on next cycle.
4.6 Calendar horizon
extend-calendar-horizonwith various property catalogs; idempotent across two consecutive runs; missing rows precisely identified; new room introduced mid-horizon → all future dates updated.
4.7 Reconciliation
reconcile-calendar-summaryagainst a deliberately-driftedavailability_calendarsrow; drift detected, fixed, alert fires once.
5. Concurrency — the Jepsen-style consistency check
Lives under test/jepsen/, runs nightly in staging and on every release-candidate.
The harness:
- Spins up Postgres in a 2-node setup (HA primary + read replica) inside Testcontainers.
- Runs N=8 worker processes, each generating randomized requests against
inventory-service:place-hold,commit,release,block-create,walk-in,group-hold. - Injects faults via
toxiproxybetween services and Postgres: 200 ms latency spikes, 5% packet loss, periodic 30-second blackouts of the primary, and clock skew on workers (±2 s). - After every run, executes the safety property check:
- For every
(tenant_id, room_id, stay_date), the sum of active (held + committed) allocations referencing that room-night is ≤ 1. - For every
(tenant_id, property_id, room_type_id, stay_date),held + committed + oos_blocked ≤ total + overbooking_cap. - Every emitted
inventory.allocation.confirmed.v1is matched by a row inroom_allocationswith consistent fields. - Every emitted
inventory.allocation.released.v1corresponds to a row inreleasedstate. - No
inventory.overbooking_actualevent observed (the always-zero counter from OBSERVABILITY §3).
- For every
- On any property violation, the harness fails CI with the offending
(tenant, property, room, date)and the operation history.
This test is the strongest argument the service can make for "zero false overbooking ever." It is a gate for promotion to staging.
6. Contract tests
- Pact (consumer): every produced subject has a contract; consumers (
reservation-service,search-aggregation-service,analytics-service,notification-service,audit-service,bff-backoffice-service,bff-tenant-booking-service,sync-service) verify against the broker. - Pact (provider): every consumed subject has a contract verifying our handlers' assumptions about
reservation-serviceandproperty-servicepayloads. - OpenAPI snapshot diff: breaking changes block the PR.
- Schema registry: every produced subject is validated against the JSON Schema in EVENT_SCHEMAS; CI fails on drift.
7. End-to-end
Top user journeys, run nightly via Playwright in staging:
- Backoffice walk-in — front desk picks a room, allocation persists, calendar grid updates, key issuance event arrives at
lock-integration-servicemock. - Block + reaccommodation — operator marks a room OOO with overlapping confirmed reservation;
reservation-serviceruns room-change sub-saga; UI surfaces alternative room. - Group hold — sales operator holds 5 rooms atomically; partial inventory simulated to verify all-or-none behavior.
- Funnel availability — guest funnel availability call returns expected numbers under cache miss and cache hit paths.
- Hold expiry — operator places a hold via API, leaves it, sweeper releases, calendar grid reflects within 30 s.
Each journey asserts both the BFF response and the resulting events on the Pub/Sub emulator.
8. Performance / load
- 500 concurrent
availability/searchrequests / 100 concurrentholdsper tenant: p99 < 300 ms search / 200 ms hold. - 10,000 allocations / hour for a 50-property tenant: outbox lag < 5 s p99.
- Sweeper handles a 50,000-stale-hold backlog within five 30-s passes.
- Group hold of 10 rooms for 7 nights: completes < 500 ms p99.
k6 scripts live under test/perf/, weekly in staging.
9. Test data builders
Under src/domain/__builders__/ and test/__builders__/:
aRoomTypeInventory()(.empty(),.withTotal(),.full()).aRoomAllocation()(.held(),.committed(),.released(),.forRoom()).anInventoryBlock()(.ooo(),.maintenance()).anOverbookingPolicy()(.disabled(),.cappedAt()).aStayWindow()(.nights()).anEventEnvelope()andaPubSubMessage().
Builders are pure-TS, framework-free, and produce strict domain types.
10. Cross-references
- Mandatory three integration tests: SERVICE_TEMPLATE §Mandatory tests
- Coverage thresholds: DEFINITION_OF_DONE
- Failure runbooks (used by chaos drills): FAILURE_MODES
- Observability metrics that back SLOs: OBSERVABILITY §3
- Reservation testing strategy: reservation-service TESTING_STRATEGY