Skip to main content

search-aggregation-service — TESTING_STRATEGY

Companion: SERVICE_OVERVIEW · DOMAIN_MODEL · APPLICATION_LOGIC · DATA_MODEL · SECURITY_MODEL · ../../docs/standards/SERVICE_TEMPLATE.md

1. Test pyramid (target ratios)

LayerTargetTooling
Domain unit (pure TS, no I/O)≥ 70 % of test countVitest
Application unit (handlers with port mocks)≈ 20 %Vitest + handcrafted port stubs
Infrastructure adapter unit≈ 5 %Vitest + Testcontainers (per-adapter)
Integration (full slice with real Postgres+OpenSearch+Pub/Sub emulator+Redis)≈ 4 %Vitest + Testcontainers
Contract (event + REST)≈ 1 %AsyncAPI validator + Pact for REST
End-to-end (deployed)small smoke setPlaywright via bff-consumer-service

Coverage gates (CI hard-fails below): domain ≥ 95 %, application ≥ 90 %, overall ≥ 85 %.

2. Mandatory test suites (per SERVICE_TEMPLATE.md)

The platform requires every service to ship three integration suites:

  1. tenant-isolation.spec.ts — for this service the suite is inverted and significantly expanded. It must:
    • Assert that public read endpoints return data spanning multiple tenantId values (the cross-tenant guarantee).
    • Assert that operator boost-rule endpoints strictly reject cross-tenant writes (MELMASTOON.SEARCH.BOOST_RULE_SCOPE_VIOLATION).
    • Assert that no response body ever includes tenantId (audit-only field).
    • Assert that the field-level allow-list holds: load every HotelIndexEntry and verify zero forbidden field exists.
    • Assert that tenant.deleted.v1 cascades to remove all rows for that tenant within the SLO window.
  2. outbox.spec.ts — write a domain change in a transaction, then crash before publishing; assert that the outbox publisher recovers and emits projection.updated.v1 exactly once after restart, and that the inbox dedupes a duplicate retry of the same upstream event.
  3. inbox.spec.ts — replay the same upstream property.published.v1 3 times; assert one applied, two dropped_stale/duplicate. Also: out-of-order pricing.rate_plan.updated.v1 with older vectorClock is rejected, while newer is applied.

These suites must run on every PR and on every release branch.

3. Test data builders

test/builders/ hosts deterministic builders:

  • hotelIndexEntryBuilder() — emits a valid HotelIndexEntry with allow-list-only fields and a configurable region/language/amenity profile.
  • propertyPublishedEventBuilder() — generates upstream events with realistic vector clocks and trace contexts.
  • searchQueryBuilder() — composes valid query payloads (text/dates/filter/sort).
  • boostRuleBuilder() — emits valid scopes, multipliers, and date ranges.
  • fxSnapshotBuilder() — builds FxSnapshot with multiple base/target pairs.

All builders accept overrides; defaults are pinned per region (AF, TJ, IR, EU, US) so tests don't drift.

4. Domain-layer tests

Cover every invariant on HotelIndexEntry, RateSnapshot, AvailabilityHint, BoostRule, IndexBuild. Examples:

  • HotelIndexEntry.upsertSlice rejects payload containing forbidden field (compile-time + runtime).
  • HotelIndexEntry.applyVectorClock is monotonic per slice (older vectorClock rejected).
  • HotelIndexEntry.transitionToSuppressed only allowed from active; idempotent.
  • RateSnapshot.cheapest correctly picks the lowest cheapestBaseMicro among candidate plans, ties broken by refundability then plan id.
  • BoostRule.activate requires status='draft' and a non-expired expiresAt.
  • BoostRule.scopeViolation raised when scope.tenantId !== rule.tenantId.
  • IndexBuild.advancePhase only allows lawful transitions (table in DOMAIN_MODEL).

Property-based tests (fast-check) for:

  • geohash5 derivation is stable for the same geo and reversible to a 5-char prefix.
  • Money micro-unit arithmetic is associative and avoids float drift.
  • vectorClock merge is associative and idempotent across slice writers.
  • canonicalQueryHash is invariant under whitespace, casing, and locale-equivalent NFKC inputs (per text normalization rules).

5. Application-layer tests

Each command and query handler is tested with handcrafted port stubs:

  • UpsertHotelIndexEntryHandler:
    • happy path applies allow-list filter, advances vector clock, persists, writes outbox row, mirrors to OpenSearch, invalidates cache.
    • stale vectorClock ⇒ short-circuits, no outbox write.
    • allow-list strip ⇒ counter increments, persists filtered version.
    • upstream payload missing required allow-list field ⇒ rejects with domain error.
  • ExecuteSearchHandler:
    • intent cache hit short-circuits orchestrator call.
    • OpenSearch failure ⇒ falls back to Postgres-only path, sets degradationLevel='opensearch_unavailable', emits query.executed.v1 with degradationLevel set.
    • from + size > 10 000MELMASTOON.SEARCH.PAGE_OUT_OF_RANGE.
    • currency conversion uses cached FX snapshot version; missing pair ⇒ priceFromConverted = null, log warn.
  • RecordClickHandler: idempotent on (searchQueryId, propertyId, rank).
  • PurgeTenantFromIndexHandler: cascades across hotel_index_entries, rate_snapshots, availability_hints, boost_rules, anonymizes click_events.user_bucket, emits ack event.
  • StartIndexRebuildHandler: refuses overlapping rebuild for same region (only_one_active_per_region).

6. Infrastructure adapter tests (Testcontainers)

Each adapter has a small focused suite:

  • PostgresHotelIndexRepository — CRUD, partial unique constraints, RLS sentinel enforcement, EXPLAIN that uses expected indexes for top 10 queries.
  • OpenSearchEnginePort — index template apply, document upsert, search DSL templating, k-NN vector search (Phase 2+), shard-failure handling.
  • RedisCachePort — set/get with TTL and snappy compression, surrogate-key purge, fallback when AUTH rotation fires.
  • PubSubOutboxPublisher — publishes ordered messages by propertyId, retries with backoff, marks published_at, leaves outbox row on failure.
  • PubSubInboxConsumer — dedupe by event_id, ack on success, nack on transient error, DLQ on persistent error after policy retries.
  • AiOrchestratorAdapter — JWT minted to orchestrator, 800 ms timeout, response schema validation, cache write/read.
  • FxSnapshotAdapter — pulls latest snapshot version, writes through Redis.

7. Integration / slice tests

A real Postgres (with PostGIS), OpenSearch (testcontainers opensearchproject/opensearch:2.13), Pub/Sub emulator, and a redis:7-alpine Memorystore stand-in:

  • Projection slice — publish a sequence of upstream events through the emulator; assert Postgres + OpenSearch state converges; assert projection.updated.v1 published with the right ordering key.
  • Search slice — seed 50 hotels across regions; execute representative queries; assert deterministic top-N ordering for fixed seeds; assert facet counts.
  • Tenant cascade — publish tenant.deleted.v1; assert all rows for that tenant are gone within 60 s; assert single tenant.purge_completed.v1 event.
  • OpenSearch fallback — kill the OpenSearch container mid-test; assert the slice degrades to Postgres path; bring it back; assert recovery and clear degradationLevel.
  • Index rebuild — orchestrate IndexBuild against a small replay archive; assert atomic alias swap and rollback path.
  • Outbox crash — kill the publisher pod between tx commit and publish; assert post-restart exactly-once publish, dedupe by event_id on retry.

8. Contract tests

ContractHow
AsyncAPI (events)services/search-aggregation-service/contracts/asyncapi.yaml validated against every published event in CI; consumers (analytics-service) run schema-compatibility checks against this file in their own pipelines.
OpenAPI (REST)services/search-aggregation-service/contracts/openapi.yaml generated from controllers; CI fails on diff. Pact (or compatible consumer-driven contract) for bff-consumer-service.
OpenSearch index templateJSON committed; CI loads it into a Testcontainers OpenSearch and verifies field set equals the allow-list.
Postgres schemapgmigrate plus snapshot diff against expected.schema.sql.

9. Performance & load tests

  • k6 query load — 1 000 RPS sustained for 10 min, mixed query shapes; assert p95 < 250 ms, error rate < 0.5 %, no outbox backlog.
  • k6 detail load — 500 RPS for 5 min on a hot subset; assert cache hit ratio ≥ 80 %, p95 < 200 ms.
  • OpenSearch sweep — 50 concurrent geo + amenity queries with wide bbox; assert no shard rejections.
  • Projection ingest burst — synthetic 50 events/sec for 10 min from pricing-service and inventory-service simultaneously; assert freshness p95 < 30 s.
  • Performance CI runs nightly against staging; results uploaded to BigQuery and compared to last 7-day baseline (regressions > 15 % page release manager).

10. Security tests

TestFrequency
Allow-list type guard (type-allowlist.spec.ts)every PR
Schema diff vs allow-list (schema-allowlist.spec.ts)every PR
OpenSearch index template diff vs allow-listevery PR
Cursor forgery test (sign with wrong key, mutate, re-use cross-region)every PR
OPA policy unit tests for search-admin.regoevery PR
Rate-limit fuzz (k6)nightly
Annual pentest scope (per SECURITY_MODEL § 13)yearly
Dependency CVE scan (Snyk) + SBOM diffper build

11. AI test surface

  • Cache hit/miss path tested with deterministic stub orchestrator.
  • 800 ms timeout enforced and asserted.
  • Allow-list applies to embedding text generator (no forbidden field can appear in the source string).
  • HITL gate test: AI summary missing hitlReviewed=true is never returned.

12. Test data hygiene & determinism

  • Tests are seeded with deterministic ULIDs (@melmastoon/test-ulid with fixed entropy).
  • Date.now and Math.random mocked via @sinonjs/fake-timers and prand.
  • Fixed timezone UTC for date math; tz=Asia/Kabul golden tests for region pinning logic.
  • All tests run with LANG=en_US.UTF-8 plus dedicated multi-script fixtures for Pashto/Dari/Tajik/Russian search collation.

13. CI matrix

JobRuns
unitevery push
integrationevery push
contractevery push
lint + typecheck + dep-auditevery push
coverage gateevery push
migration plan (expand→backfill→contract dry run on a fresh DB)every push
security (SAST + secret scan + cosign verify)every push
nightly perfevery night against staging
nightly chaosweekly (kills OpenSearch and one consumer pod for 2 min)

14. Local test conveniences

pnpm test:slice -- search boots Testcontainers and runs the integration slice locally. pnpm test:opensearch-template validates the index template against the running container. pnpm test:type-allowlist runs the type-walk fast.