property-service — TESTING_STRATEGY
Companion: DOMAIN_MODEL · APPLICATION_LOGIC · API_CONTRACTS · EVENT_SCHEMAS · SYNC_CONTRACT · SECURITY_MODEL · FAILURE_MODES
This document is the binding test specification for property-service. It defines the test pyramid layout, coverage gates, scenario lists per layer, contract/consumer-driven testing posture, low-bandwidth and offline chaos, security tests, and CI gating.
Frameworks: Vitest for unit + integration; Pact (provider verification) for HTTP contracts; AsyncAPI + Avro schema diff for events; Testcontainers for Postgres + Pub/Sub emulator + Memorystore; Playwright at the BFF/UX layer (out of this service's direct ownership but referenced).
1. Test Pyramid
| Layer | Frameworks | Coverage gate | Where | Owns |
|---|---|---|---|---|
| Unit (domain + use cases) | Vitest, fast-check | ≥ 90 % lines, ≥ 85 % branches in src/domain/** and src/application/** | **/*.spec.ts | Aggregate invariants, value object validation, command handlers with mocked ports |
| Integration | Vitest + Testcontainers (Postgres 15 + PostGIS, Pub/Sub emulator, Memorystore emulator) | ≥ 80 % lines in src/infrastructure/** | **/*.int-spec.ts | Repository SQL, RLS enforcement, outbox/inbox transactional semantics, cache key behavior |
| Contract — HTTP (provider) | Pact verification against contracts published by bff-backoffice-service, bff-tenant-booking-service, search-aggregation-service, housekeeping-service | 100 % of consumer states pass | pacts/**/*.json | API stability |
| Contract — Events (producer) | AsyncAPI lint + schema-registry compatibility check | 0 breaking changes without an Xxx.v(n+1) topic | events/asyncapi.yaml | Event stability |
| End-to-end (service-local) | Vitest with full stack via docker compose | smoke + critical paths | e2e/**/*.e2e-spec.ts | Realistic create→publish→archive flows |
| Cross-service E2E | Playwright at the BFF + UI level | platform-owned | external | Booking happy path; smoke ownership not in this service |
Coverage gates are enforced in CI; below threshold = failing build.
2. Unit Tests
2.1 Domain (≥90 % required)
Aggregate-level scenarios — every invariant from DOMAIN_MODEL §3:
Property.publishrejects whenrooms.count(active) < 1→MELMASTOON.PROPERTY.NO_ACTIVE_ROOMS_TO_PUBLISH.Property.publishrejects when default-locale translation missing.Property.publishrejects when geo missing.Property.publishrejects when no hero photo.Property.archiverejects when status ispublished(mustunpublishfirst).Room.changeStatusenforces transition matrix; rejects illegal transitions withMELMASTOON.PROPERTY.ILLEGAL_STATUS_TRANSITION.Room.changeStatus(active → out_of_order)requires non-emptyreason.Room.archiverejects when there is an active reservation reference (provided via port; mocked).RoomTypecode uniqueness per property (handled at repo, but assertion lives at use case).BedConfig: exactly one default per room type.Photo.setHeroenforces single hero per property invariant.PolicyOverride.setvalidates kind and shape.PropertyAmenity.setdeduplicates and rejects unknown codes.Property.translations.upsertrequires at least one locale to be the configureddefaultLocale.- Property/Room cross-tenant constructor rejects with
CrossTenantReferenceError.
2.2 Property-based (fast-check)
- For any non-empty room set with status drawn from the lifecycle, applying any valid transition sequence preserves the invariant
archived ⇒ no further transitions. - For any sequence of
setHerocalls, only one row ends withis_hero=true. - For any address with mixed Latin + native script, the canonical translation upsert preserves both fields losslessly.
- For any geohash bounding box and N synthetic properties uniformly distributed, the in-memory geo filter agrees with the PostGIS-backed one (sample N=1000).
2.3 Use cases
Every command handler (commands/*.handler.ts) has at least:
- happy path
- precondition rejected (returns specific error code)
- authorization denied path (mocked
AuthorizationPort) - tenant mismatch path
- idempotency replay path (same key, same body → no-op; same key, different body →
MELMASTOON.GENERAL.IDEMPOTENCY_KEY_REPLAYED_DIFFERENT_BODY)
Every query handler has at least:
- happy path
- empty result
- RLS-driven empty result (caller from another tenant)
3. Integration Tests
3.1 Persistence
- Every multi-tenant table must have a
*_tenant_isolation.int-spec.tsthat:- Inserts row R as tenant A.
- Switches connection context to tenant B (
SET LOCAL app.tenant_id). - Asserts:
SELECT … WHERE id = R.idreturns 0 rows. - Asserts:
UPDATEandDELETEof R from tenant B context affect 0 rows.
- Repository SQL: every method exercised against real PostGIS-enabled Postgres.
- Geo:
- bounding box query returns expected fixture set.
- nearby (radius) query orders by distance.
- GIST index used (verified via
EXPLAIN).
- Outbox:
- aggregate save + outbox row are written in the same transaction; rollback removes both.
- publisher marks
published_at, incrementsattemptson failure. - DLQ path on max attempts.
- Inbox:
- duplicate
messageIdis short-circuited. - failure path keeps
processed_atnull and incrementsattempts.
- duplicate
- Cache:
- hit / miss / fill telemetry asserted.
- invalidation on
property.updated.v1clears expected keys.
3.2 Event consumers
tenant.created.v1→ tenant becomes "allowed" for property creation.tenant.deleted.v1→ cascade unpublish + archive sequence emitted.housekeeping.room.maintenance_required.v1→ auto-OOO; ignored when room is already OOO/OOS/archived.maintenance.work_order.completed.v1→ auto-RTS only when previous OOO source was a maintenance event with the same work-order id; otherwise no-op.- Consumer is idempotent (replays produce the same final state).
3.3 Sync
- Pull endpoint paginates correctly across cursor boundaries.
If-None-Matchreturns304-style empty payload when nothing changed.- Push: each conflict resolution path covered (
fast_forward,lww_status,notes_three_way_merge,hero_lww,tombstone). - Push: rejection paths exercised (
expectedVersionmismatch, illegal transition).
4. Contract Tests
4.1 HTTP provider verification (Pact)
Verify against pacts published by:
bff-backoffice-service(operator console)bff-tenant-booking-service(tenant booking site catalog)search-aggregation-service(consumer meta projection refresh)housekeeping-service(room status feedback) — internal RPC, also via Pactlock-integration-service(PUT /rooms/:id/lock-binding)
Provider states required:
a tenant exists with id tnt_seeda published property exists with id ppt_seeda draft property exists with no roomsa property exists with 5 active roomsa room exists with status activea room exists with status out_of_orderan idempotency key has been used
Failure to honor any contract = failing build; consumers cannot land breaking changes without coordinated pact update.
4.2 Event producer compatibility
- Every published topic in EVENT_SCHEMAS has an Avro/JSON-schema in
events/schemas/. The CI gate runsschema-diffagainst the registry; only forward-compatible changes pass on the samevN. Breaking changes require a newvN+1topic and an explicit deprecation note.
5. End-to-End (service-local)
e2e/** scenarios run against the full docker compose with wiremock for iam-service, file-storage-service, ai-orchestrator-service, and geo-service:
- Create → publish → unpublish → archive of a property in Kabul, including translations (
ps-AF,en,fa), hero photo, 3 room types, 12 rooms. - Bulk room create (50 rooms) — all-or-nothing atomicity verified.
- OOO from front desk — push from sync, server emits expected event, downstream wiremock receives it.
- Photo upload happy path — signed URL, upload, scan-clean event, photo becomes
ready, event emitted. - Photo quarantine — scan event with verdict
infected; photo not promoted; operator notified path captured. - AI describe-draft accept — staged suggestion → operator accepts → translation row updated with
aiProvenance. - Tenant deletion cascade —
tenant.deleted.v1consumed; properties archived, photos removed, events emitted in order. - Reservation-block on room archive — reservation port mock returns "active reservation"; archive rejected with the expected code.
- Cross-tenant isolation — operator A cannot read or mutate operator B's property by direct ID.
6. Low-Bandwidth & Offline Chaos
- Sync 24h offline replay. Generate 200 status changes offline; verify push completes within 60 s and produces correct event stream (in monotonically increasing order per room).
- High latency (RTT 1.5 s). Pull/push still completes within SLO with adjusted thresholds; backpressure observed.
- Packet loss (10 %). No duplicates after replay (idempotency).
- Partial cursor regression. Force a server cursor regression scenario; client recovers via the documented full-reset fallback (SYNC_CONTRACT §7).
- Pub/Sub outage. Outbox grows; publisher backs off; on recovery, drains within 5 min for a 10k-row backlog.
- Cloud SQL failover. Reads succeed on read replica; writes fail-fast with
MELMASTOON.GENERAL.SERVICE_DEGRADEDuntil primary returns; no data loss.
7. Security Tests
- AuthN. Reject expired tokens, wrong
aud, wrongiss, missingkid, JWKS rotation honored. - AuthZ. Per-route allow/deny matrix exercised; OPA policy decisions are unit-tested separately and verified against the actual bundle in CI.
- Tenant isolation. Per §3.1; plus a fuzzing job that crafts random property IDs as tenant B and confirms 404 (not 403) leakage of existence.
- Idempotency replay. Verified per use case.
- Signed URL safety. Photo uploads cannot exceed declared size; mime-mismatch payload is rejected by
file-storage-serviceand the row staysuploaded. - SQLi & path traversal smoke. Static (semgrep) + dynamic (zaproxy in CI) — gating only on high severity.
8. Performance & Load
- Read baseline. k6 script: 200 RPS for
GET /properties/:idon cached path; p99 < 80 ms; cache hit > 95 %. - Cold geo-search.
GET /properties/geo/nearby50 RPS, p99 < 400 ms with PostGIS GIST index, no cache. - Bulk room create. 100 rooms in one request completes < 2 s and emits 100 distinct
property.room.created.v1events. - Photo pipeline. 50 concurrent uploads complete
uploaded → readywithin p95 < 30 s. - Sync push. 60 push requests/min/device sustained; no 5xx.
9. Test Data Strategy
- Deterministic ULIDs in tests via a seeded factory; never wall-clock
now()directly in assertions. fixtures/ships seeds for the 3-property local-dev scenario (Kabul, Dushanbe, Tehran).- Property names and addresses include mixed RTL/LTR scripts in fixtures to surface bidi rendering bugs early.
10. CI Gating
Every PR must pass:
- lint (
eslint --max-warnings 0) - type check (
tsc --noEmit) - unit tests + coverage gates
- integration tests (Testcontainers)
- provider Pact verification (against the latest published consumer pacts)
- AsyncAPI lint + schema compatibility check
- semgrep + npm audit (high-severity = block)
- OpenAPI lint + breaking-change diff (block on undeclared breaking change)
Nightly:
- E2E suite (full docker compose)
- Performance baseline (drift > 20 % vs last green = ticket)
- Tenant isolation fuzzer (1 hour)
Runbooks for failure scenarios live alongside FAILURE_MODES; test seed data is documented in LOCAL_DEV_SETUP.