Skip to main content

Number Intelligence Service — Testing Strategy

Version: 1.0 Status: Draft Owner: Messaging Core / Quality Engineering Last Updated: 2026-04-21 Companion: APPLICATION_LOGIC · SECURITY_MODEL · FAILURE_MODES

1. Test pyramid

┌─────────────────────────┐
│ E2E (6–10 flows) │ Playwright + k6 + Toolium
├─────────────────────────┤
│ Contract (12+) │ Pact + gRPC reflection
├─────────────────────────┤
│ Integration (70+) │ Real PG + Redis + NATS + mock MNO SFTP + mock SS7 + Testcontainers
├─────────────────────────┤
│ Unit (300+) │ Domain, VOs, matchers, hash-chain, state machine
└─────────────────────────┘

Coverage targets:

  • Domain layer: ≥ 95 % line, ≥ 90 % branch
  • Application layer (use cases): ≥ 90 % line, ≥ 85 % branch
  • Adapters (PG, Redis, NATS, SFTP, SS7-gateway-client): ≥ 80 % line
  • Aggregate: ≥ 85 % line / ≥ 80 % branch (platform DoD)

2. Unit tests

Framework: Jest + ts-jest.

2.1 MSISDN normalisation (Msisdn VO)

describe('Msisdn VO', () => {
it.each([
['+93701234567', true], // AF mobile, canonical
['+93 701 234 567', false], // whitespace not allowed
['0701234567', false], // missing CC
['+9370A234567', false],
['+93000000000', true], // zero-padded valid E.164
['+447911123456', true], // foreign roaming OK at VO level
['+931', false], // too short
['+9370123456789012345', false], // too long
['‎+93701234567', false], // LTR mark rejected
])('validates %s -> %s', (input, ok) => { /* ... */ });
});

2.2 IMEI validation (Luhn per 3GPP TS 23.003 §6.2.1)

describe('Imei Luhn', () => {
it('accepts valid 15-digit Luhn IMEI', () => { /* TAC+SNR+check */ });
it('rejects 14-digit IMEI (SV truncated)', () => { /* ... */ });
it('rejects non-digit characters', () => { /* ... */ });
it('rejects zero IMEI', () => { /* ... */ });
it('rejects IMEISV (16 digits)', () => { /* ... */ });
});

2.3 Hash-chain integrity

describe('HashChain (portability_history and lookup_audit)', () => {
it('first row of partition uses prev_hash = 0x00…32', () => { /* ... */ });
it('record_hash = sha256(canonical(payload) || prev_hash) per RFC 8785', () => { /* ... */ });
it('canonical serialisation is deterministic across key order and whitespace', () => {
const a = canonicalise({ b: 2, a: 1 });
const b = canonicalise({ a: 1, b: 2 });
expect(a).toBe(b);
});
it('verifier returns CHAIN_BROKEN when middle row prev_hash mutated', () => { /* ... */ });
it('verifier handles signing_key_id rotation across consecutive rows', () => { /* ... */ });
});

2.4 MNP state machine

describe('NumberRecord.mnpStatus transitions', () => {
it('UNKNOWN -> NATIVE on first HLR (donor == owning MNO)', () => { /* ... */ });
it('UNKNOWN -> PORTED_IN on first MNP record where recipient != donor', () => { /* ... */ });
it('NATIVE -> PORTED_IN on MNP record', () => { /* ... */ });
it('PORTED_IN -> NATIVE on MNP record where donor == original (restored)', () => { /* ... */ });
it('PORTED_IN -> PORTED_OUT on OUT direction', () => { /* ... */ });
it('rejects native_no_original_mno invariant violation', () => { /* ... */ });
});

2.5 MNP conflict resolver (weighted score)

describe('ConflictResolver', () => {
it('prefers later sourceFeed timestamp (30% weight)', () => { /* ... */ });
it('prefers later portDate (25% weight)', () => { /* ... */ });
it('applies HLR probe concurrence (30% weight)', () => { /* ... */ });
it('weighted score > 0.7 triggers auto-propose, else manual review', () => { /* ... */ });
it('fraud-intel flagged MNO nudges the opposite side (15%)', () => { /* ... */ });
});

2.6 Per-MNO TPS governor (Lua token bucket)

describe('TPS governor', () => {
it('admits up to capacity within a second', () => { /* ... */ });
it('denies on exhaustion; returns 0', () => { /* ... */ });
it('refills at configured rate', () => { /* ... */ });
it('EXPIRE applied on every eval (keeps key hot)', () => { /* ... */ });
});

2.7 Tenant quota enforcer

describe('TenantQuotaEnforcer', () => {
it('enforces RPS bucket per tenant', () => { /* ... */ });
it('separate fresh-lookup sub-bucket with lower cap', () => { /* ... */ });
it('monthly counter expires at 1st Asia/Kabul', () => { /* ... */ });
it('plan change propagates within 60s', () => { /* ... */ });
it('429 emits audit.lookup.quota_exceeded.v1', () => { /* ... */ });
});

2.8 E.164 prefix table (fallback)

describe('PrefixTable (fallback)', () => {
it('maps +9370 → afghan-wireless (example)', () => { /* ... */ });
it('maps +9377 → mtn-afghanistan (example)', () => { /* ... */ });
it('returns UNKNOWN mno for unassigned prefix', () => { /* ... */ });
it('loads from ATRA-published numbering plan CSV at startup', () => { /* ... */ });
});

2.9 Redactor (LLM input)

describe('AI redactor', () => {
it('replaces all MSISDNs with [MSISDN_N]', () => { /* ... */ });
it('replaces all 15-digit IMSI/IMEI with [DIGITS]', () => { /* ... */ });
it('throws if called on raw-MSISDN input', () => { /* ESLint + runtime guard */ });
});

3. Integration tests

Framework: Jest + Testcontainers (Postgres 16 + Redis cluster 7 + NATS 2.10 JetStream + mock MNO SFTP + mock SS7 gateway + MinIO).

3.1 Cache cascade (ResolveMsisdn)

describe('ResolveMsisdn cascade', () => {
beforeEach(async () => seedPgWithSample());

it('LRU hit → P95 ≤ 1 ms, source=lru', async () => { /* ... */ });
it('Redis hit → populates LRU; P95 ≤ 4 ms', async () => { /* ... */ });
it('Postgres hit → populates Redis and LRU with per-class TTL', async () => { /* ... */ });
it('on stale PG row + forceFresh=true → live HLR probe fires', async () => { /* ... */ });
it('on live HLR timeout → returns stale row with confidence=LOW', async () => { /* ... */ });
it('PG row MNP divergence → overlays with MNP registry + emits divergence event', async () => { /* ... */ });
});

3.2 Load test — hot path

// k6 script: 10 000 RPS sustained 15 min; 99% cache-hit scenario
// Asserts: P95 ≤ 5 ms; P99 ≤ 20 ms; 0 errors; cache_hit_ratio ≥ 95%
// Separate run: 2 000 RPS with 20% cache-miss (PG path); P95 ≤ 20 ms

3.3 MNP reconciliation

describe('UC-MnpReconciliationDaily', () => {
it('pulls mock MNO SFTP file; archives to MinIO with sha256 tag', async () => { /* ... */ });
it('inserts valid non-conflicting rows; no-ops duplicates on re-run', async () => { /* idempotency */ });
it('detects and inserts conflict for ±2 day overlap with another MNO', async () => { /* ... */ });
it('emits one numint.reconciliation.completed.v1 per MNO', async () => { /* ... */ });
it('chain hash advances correctly across consecutive runs per MNO', async () => { /* ... */ });
it('SFTP unreachable → retries hourly up to 23:00; emits NumIntMnpReconciliationStale at 26h', async () => { /* ... */ });
});

3.4 HLR probe (mock SS7 gateway)

describe('ProbeHlr', () => {
it('MAP transport: gateway builds SendRoutingInfoForSM per 3GPP TS 29.002; returns imsi,vlr', async () => { /* ... */ });
it('REST transport: POST /v1/hlr/lookup with client-credentials JWT; parses response', async () => { /* ... */ });
it('timeout returns DEADLINE_EXCEEDED; caller gets stale fallback', async () => { /* ... */ });
it('MAP abort returns MAP_ABORT status', async () => { /* ... */ });
it('PCAP sample captured at 0.1% rate; encrypted to MinIO', async () => { /* ... */ });
it('per-MNO TPS governor bounds concurrent probes', async () => { /* ... */ });
});

3.5 Tenant quota enforcement (Public Lookup API)

describe('Public Lookup quota', () => {
it('RPS bucket rejects with 429 beyond plan cap', async () => { /* ... */ });
it('monthly counter persists across RPS resets; 429 at cap; resets Asia/Kabul 1st', async () => { /* ... */ });
it('fresh-lookup sub-bucket denies maxStaleness<86400 separately', async () => { /* ... */ });
it('plan change (billing event) propagates within 60s', async () => { /* ... */ });
it('internal gRPC callers are not metered', async () => { /* SPIFFE SAN bypass */ });
});

3.6 Cache tiering under cold start

describe('Cold start warm-up', () => {
it('pod comes up; readiness gate blocks until 80% of top-N warmed', async () => { /* ... */ });
it('warm cron runs hourly; pre-loads from Postgres top-N by lookup_count', async () => { /* ... */ });
it('emits numint.cache.refreshed.v1 per warm run', async () => { /* ... */ });
});

4. Contract tests

Pact (REST) + gRPC reflection-based contract tests for each internal consumer.

ConsumerContract
routing-engineResolveMsisdn request/response shape; fail-degraded path returns UNKNOWN; LookupPorting semantics
sms-firewall-serviceResolveMsisdn + LookupEir + EIR observation attached to attribution response
compliance-engineResolveMsisdn for GEO_RESTRICTION rule; confidence=UNKNOWN must map to "most-restrictive"
channel-router-serviceResolveMsisdn lineType=MOBILE capability gate
fraud-intel-servicenumint.mnp.divergence.v1, numint.hlr_probe.completed.v1, numint.mnp.churn_anomaly.v1 event shapes
billing-servicenumint.lookup.billed.v1 schema + idempotency on Nats-Msg-Id
Public Lookup API (tenant)/v1/lookup/{msisdn} response shape; 400/429/503 error envelopes

Pact verification runs on every CI build and on deploy to staging; failures block promotion.

5. E2E scenarios (Playwright + k6 + Toolium)

5.1 Full MNP journey

  1. Pre-state: MSISDN +93701234567 owned by Afghan Wireless (seeded).
  2. Lookup via routing-engine → response shows mno: afghan-wireless.
  3. Inject MNP file listing this MSISDN as ported to MTN (mock MNO SFTP fixture).
  4. Trigger mnp-recon cron on demand.
  5. Verify numint.mnp.changed.v1 event consumed by routing-engine.
  6. Re-lookup → response now shows mno: mtn-afghanistan, mnpStatus: PORTED_IN.
  7. Assert cache was invalidated (Redis key absent on step 5).

5.2 MNP conflict resolution

  1. Inject overlapping MNP files from both Afghan Wireless and MTN for the same MSISDN within ±2 days.
  2. Verify ReconciliationConflict row created; NumberRecord.mnpStatus unchanged.
  3. Admin UI shows conflict with deterministic score + LLM triage summary.
  4. Admin resolves B_WINS; verify PortabilityRecord inserted; numint.mnp.changed.v1 emitted.

5.3 Public Lookup tenant journey

  1. Tenant calls GET /v1/lookup/+93701234567 with JWT; 200 returned with typed attribution.
  2. billing.metering.recorded.v1 emitted with correct SKU.
  3. Tenant exceeds RPS bucket; 429 returned with Retry-After.
  4. Tenant retries; success.
  5. Audit query (admin) returns the call log with tenant-salted hash.

5.4 Cache cold start

  1. Restart all NI pods in kbl.
  2. Assert no pod becomes ready until warm-on-deploy loads ≥ 80 % of top-N.
  3. Traffic is served from mzr during the window; no visible outage.
  4. Verify numint.cache.refreshed.v1 emitted.

6. Chaos tests

ScenarioExpected behaviour
Kill Postgres primaryHot reads from Redis; writes fail 503; Patroni promotes standby within 90 s; traffic resumes
Kill Redis clusterCascade falls to PG; latency P95 ≤ 20 ms; cache warms on recovery
Kill NATSOutbox accumulates; events delayed; hot path unaffected; alert fires
Inject MNP file tamper (mutated row)Chain verifier detects on next run; CRITICAL alert; writes freeze pending investigation
Inject HLR gateway pod failureSibling pod takes over; synthetic probe recovers in < 30 s
Network partition kbl↔mzrBoth regions continue hot reads; MNP jobs pause; Mazar promotes to MNP leader after 10 min
Inject synthetic MNP conflict spike (100+/hour)NumIntReconciliationConflictSpike HIGH; backlog triage UI responsive
Tenant enumeration flood (10× plan RPS)429 rate-limit kicks in; numint_public_lookup_quota_breach_total rises; NumIntPublicLookupQuotaAbuse fires; other tenants unaffected

7. Security tests

  • RLS on lookup_audit. Tenant A cannot read tenant B's rows; admin/regulator can read all.
  • Hash-chain tamper. Mutating a single byte in portability_history or lookup_audit MUST trigger verifier within 24 h.
  • Tenant-quota bypass. Attacker using forged X-Tenant-Id rejected by Kong + re-verified by RoleGuard.
  • Prefix-fallback defensibility. Prefix table loaded from ATRA CSV at startup; hash-pinned against Vault; modified CSV refused.
  • Residency. Deploy-time test asserts every configured endpoint resolves to AF IP space (MNO endpoints) or cluster-internal.
  • SS7 fuzz. Malformed MAP PDUs against ni-hlr-gateway rejected without crash.
  • Prompt-injection. Malicious MNP file content reaching LLM redactor; verified no raw MSISDN in prompt.

8. Property-based tests (fast-check)

  • MSISDN VO: for any string s, parse(canonicalise(s)) round-trips iff s is E.164.
  • Hash chain: for any sequence of payloads, verifier outputs OK; mutating any payload byte at any position MUST produce BROKEN.
  • TPS governor: over any arrival pattern, admitted rate ≤ capacity across any 1 s window (invariant).
  • Scope isolation: lookups across two tenants produce different lookup_audit.msisdn_hash values (per-tenant salt).

9. Performance regression gates

CI load test runs on every main-branch push; fails the build on any:

  • P95 ResolveMsisdn cache-hit > 5 ms (+ 20 % regression allowance for CI noise).
  • Aggregate cache hit ratio < 92 % (baseline 95 %).
  • ResolveMsisdn error rate > 0.1 %.
  • MNP recon synthetic run duration > 10 min for 10 k row fixture.

10. Test data

  • MNP fixtures. Mock MNO SFTP with 10 k-row CSVs per MNO, including edge cases: malformed E.164, past-date port, duplicate row, conflict-inducing overlap.
  • EIR fixtures. 5 k-row BLACKLIST / GREYLIST / WHITELIST mix.
  • MSISDN seed. 1 M synthetic +9379999XXXXX reserved test-range numbers; never overlap with real Afghan assignments.
  • IMEI seed. 5 k valid-Luhn test IMEIs; never real-world TAC prefixes.

All test data lives under test/fixtures/numint/ and is loaded by the Testcontainers bootstrap.