Skip to main content

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

LayerFrameworkTargetsWhere
Unit (domain)Vitest≥ 95% statements; 100% branches on RoomAllocation state machinesrc/domain/__tests__/
Unit (application)Vitest≥ 90% statements per use casetest/unit/application/
Integration (in-service)Vitest + Testcontainers (Postgres + Pub/Sub emulator)≥ 80% pathstest/integration/
ConcurrencyVitest + Postgres + worker_threadsevery advisory-lock + EXCLUDE-constraint pathtest/concurrency/
Jepsen-style consistencycustom harness on Testcontainers"no false overbooking" property under partition + clock skewtest/jepsen/
Contract (consumer)Pact (broker)every produced + consumed event has a contracttest/contract/
API contractOpenAPI snapshot diff + dreddevery endpointCI gate
End-to-endPlaywright via bff-backoffice-servicewalk-in + block + reaccommodation + group holdtest/e2e/
Chaos / loadk6 + toxiproxylatency, packet loss, disconnecttest/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

SpecCoverage
room-allocation.state-machine.spec.tsevery legal transition; every illegal transition rejected with IllegalAllocationTransition
room-type-inventory.invariants.spec.tsI2 (available ≥ 0), I10 (stop_sell), bed-vs-room consistency, oversell respect for overbooking_cap
room-allocation.invariants.spec.tsI3 (no overlap on roomId), I4 (held requires heldUntil > now), I5 (committed requires committedAt), I6 (released immutable), I9 (bed requires bedTotal)
inventory-block.invariants.spec.tswindow validity, target (room or type), source taxonomy
overbooking-policy.invariants.spec.tsI13 (cap ≥ 0; routes if enabled)
room-picker.spec.tsproperty-based: any candidate set yields a deterministic pick per strategy; housekeeping_ready_first prefers ready rooms but never deadlocks
overbooking-decider.spec.tsallow / allow_with_alert / reject thresholds across cap values; off-policy returns reject
reaccommodation-finder.spec.tsblock window vs allocation overlap math: contiguous, fully covered, partial overlap, exclusive-end
group-atomic-hold-planner.spec.tscanonical lock order is total over (property, room_type, date); avoids deadlock under shuffled inputs
calendar-horizon-extender.spec.tsinserts only forward; idempotent on re-run
stay-window-night-iteration.spec.tsnights[] 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

SpecWhat it asserts
place-hold-allocation.use-case.spec.tslock acquired in canonical order; counter mutations + insert + outbox in one transaction; allocation.failed.v1 on rejection
commit-allocation.use-case.spec.tsflips held → committed; idempotent on replay; OCC respected
release-allocation.use-case.spec.tsper reasonCode path; counter delta correct; idempotent
create-inventory-block.use-case.spec.tsReaccommodationFinder runs; reaccommodation_required.v1 emitted when overlaps; counters updated
release-inventory-block.use-case.spec.tscounters restored; block.released.v1 emitted
group-atomic-hold.use-case.spec.tsall-or-none; partial-fail rolls back; deadlock-free under stress
walk-in-allocate.use-case.spec.tssync REST path; commits directly; idempotent on Idempotency-Key
update-overbooking-policy.use-case.spec.tsrole check; OCC; audit emitted
extend-calendar-horizon.use-case.spec.tsinserts missing rows; respects per-property room-type catalog
extend-new-room-calendar.use-case.spec.tsbumps total on future dates only
expire-holds.use-case.spec.tsbatch behavior; FOR UPDATE SKIP LOCKED; idempotent on already-released rows
reconcile-calendar-summary.use-case.spec.tsrecomputes 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 return 404, cross-tenant writes raise 403/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 by event_id; idempotent on retry; consumer crash mid-handle resumes correctly.

4.2 Saga-side integration

#ScenarioAsserted state
S1reservation.held.v1 arrives, allocation succeedsroom_allocations row status='held', counters bumped, allocation.confirmed.v1 outboxed
S2reservation.held.v1 arrives, no availabilityno row, allocation.failed.v1 outboxed with reasonCode='insufficient_availability'
S3reservation.confirmed.v1 arrivesrow flips to committed, committedAt set, second allocation.confirmed.v1 outboxed
S4reservation.cancelled.v1 arrives post-confirmrow flips to released, counters decremented, allocation.released.v1 outboxed with reasonCode='reservation_cancelled'
S5reservation.dates_changed.v1 arrivesatomic release-then-allocate; if new fails, old retained, allocation.failed.v1 for the new attempt only
S6reservation.no_show.v1 arrives, tenant policy release_after_grace=truerelease with reasonCode='reservation_no_show'; if policy false, no-op
S7reservation.hold_expired.v1 arrivesrelease with reasonCode='hold_expired'
S8property.room.taken_out_of_order.v1 arrives, overlapping committed allocations existblock created, reaccommodation_required.v1 outboxed; allocations untouched
S9property.room.created.v1 arrivesfuture room_type_inventory_daily.total bumped for the room's type
S10Out-of-order: confirmed arrives before heldinbox 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 concurrent PlaceHoldAllocationUseCase for the same single available room; exactly one wins, all others get INSUFFICIENT_AVAILABILITY; assert room_allocations has 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.tscancelled and confirmed events arrive within 1 ms; deterministic outcome via inbox ordering and OCC.
  • test/concurrency/walk-in-vs-saga-race.spec.ts — operator walk-in and reservation.held.v1 arrive simultaneously for the last available room of a type; only one succeeds.
  • test/concurrency/sweeper-vs-confirm-race.spec.ts — sweeper runs while reservation.confirmed.v1 arrives for the soon-to-expire hold; sweeper must skip if committedAt is set or status advanced; confirmed wins.
  • test/concurrency/block-vs-allocation-race.spec.tsCreateInventoryBlockUseCase and PlaceHoldAllocationUseCase for the same room/night arrive concurrently; one wins under serializable invariant; the other receives INSUFFICIENT_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 the rotate-partitions job 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-horizon with 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-summary against a deliberately-drifted availability_calendars row; 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:

  1. Spins up Postgres in a 2-node setup (HA primary + read replica) inside Testcontainers.
  2. Runs N=8 worker processes, each generating randomized requests against inventory-service: place-hold, commit, release, block-create, walk-in, group-hold.
  3. Injects faults via toxiproxy between services and Postgres: 200 ms latency spikes, 5% packet loss, periodic 30-second blackouts of the primary, and clock skew on workers (±2 s).
  4. 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.v1 is matched by a row in room_allocations with consistent fields.
    • Every emitted inventory.allocation.released.v1 corresponds to a row in released state.
    • No inventory.overbooking_actual event observed (the always-zero counter from OBSERVABILITY §3).
  5. 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-service and property-service payloads.
  • 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:

  1. Backoffice walk-in — front desk picks a room, allocation persists, calendar grid updates, key issuance event arrives at lock-integration-service mock.
  2. Block + reaccommodation — operator marks a room OOO with overlapping confirmed reservation; reservation-service runs room-change sub-saga; UI surfaces alternative room.
  3. Group hold — sales operator holds 5 rooms atomically; partial inventory simulated to verify all-or-none behavior.
  4. Funnel availability — guest funnel availability call returns expected numbers under cache miss and cache hit paths.
  5. 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/search requests / 100 concurrent holds per 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() and aPubSubMessage().

Builders are pure-TS, framework-free, and produce strict domain types.


10. Cross-references