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)
| Layer | Target | Tooling |
|---|---|---|
| Domain unit (pure TS, no I/O) | ≥ 70 % of test count | Vitest |
| 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 set | Playwright 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:
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
tenantIdvalues (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
HotelIndexEntryand verify zero forbidden field exists. - Assert that
tenant.deleted.v1cascades to remove all rows for that tenant within the SLO window.
- Assert that public read endpoints return data spanning multiple
outbox.spec.ts— write a domain change in a transaction, then crash before publishing; assert that the outbox publisher recovers and emitsprojection.updated.v1exactly once after restart, and that the inbox dedupes a duplicate retry of the same upstream event.inbox.spec.ts— replay the same upstreamproperty.published.v13 times; assert one applied, twodropped_stale/duplicate. Also: out-of-orderpricing.rate_plan.updated.v1with oldervectorClockis 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 validHotelIndexEntrywith 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()— buildsFxSnapshotwith 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.upsertSlicerejects payload containing forbidden field (compile-time + runtime).HotelIndexEntry.applyVectorClockis monotonic perslice(oldervectorClockrejected).HotelIndexEntry.transitionToSuppressedonly allowed fromactive; idempotent.RateSnapshot.cheapestcorrectly picks the lowestcheapestBaseMicroamong candidate plans, ties broken by refundability then plan id.BoostRule.activaterequiresstatus='draft'and a non-expiredexpiresAt.BoostRule.scopeViolationraised whenscope.tenantId !== rule.tenantId.IndexBuild.advancePhaseonly allows lawful transitions (table in DOMAIN_MODEL).
Property-based tests (fast-check) for:
geohash5derivation is stable for the samegeoand reversible to a 5-char prefix.Moneymicro-unit arithmetic is associative and avoids float drift.vectorClockmerge is associative and idempotent across slice writers.canonicalQueryHashis invariant under whitespace, casing, and locale-equivalent NFKC inputs (pertextnormalization 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', emitsquery.executed.v1withdegradationLevelset. from + size > 10 000⇒MELMASTOON.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 acrosshotel_index_entries,rate_snapshots,availability_hints,boost_rules, anonymizesclick_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 bypropertyId, retries with backoff, markspublished_at, leaves outbox row on failure.PubSubInboxConsumer— dedupe byevent_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.v1published 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 singletenant.purge_completed.v1event. - 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
IndexBuildagainst 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_idon retry.
8. Contract tests
| Contract | How |
|---|---|
| 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 template | JSON committed; CI loads it into a Testcontainers OpenSearch and verifies field set equals the allow-list. |
| Postgres schema | pgmigrate 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-serviceandinventory-servicesimultaneously; 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
| Test | Frequency |
|---|---|
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-list | every PR |
| Cursor forgery test (sign with wrong key, mutate, re-use cross-region) | every PR |
OPA policy unit tests for search-admin.rego | every PR |
| Rate-limit fuzz (k6) | nightly |
| Annual pentest scope (per SECURITY_MODEL § 13) | yearly |
| Dependency CVE scan (Snyk) + SBOM diff | per 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=trueis never returned.
12. Test data hygiene & determinism
- Tests are seeded with deterministic ULIDs (
@melmastoon/test-ulidwith fixed entropy). Date.nowandMath.randommocked via@sinonjs/fake-timersandprand.- Fixed timezone
UTCfor date math;tz=Asia/Kabulgolden tests for region pinning logic. - All tests run with
LANG=en_US.UTF-8plus dedicated multi-script fixtures for Pashto/Dari/Tajik/Russian search collation.
13. CI matrix
| Job | Runs |
|---|---|
unit | every push |
integration | every push |
contract | every push |
lint + typecheck + dep-audit | every push |
coverage gate | every push |
migration plan (expand→backfill→contract dry run on a fresh DB) | every push |
security (SAST + secret scan + cosign verify) | every push |
nightly perf | every night against staging |
nightly chaos | weekly (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.