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.
| Consumer | Contract |
|---|---|
routing-engine | ResolveMsisdn request/response shape; fail-degraded path returns UNKNOWN; LookupPorting semantics |
sms-firewall-service | ResolveMsisdn + LookupEir + EIR observation attached to attribution response |
compliance-engine | ResolveMsisdn for GEO_RESTRICTION rule; confidence=UNKNOWN must map to "most-restrictive" |
channel-router-service | ResolveMsisdn lineType=MOBILE capability gate |
fraud-intel-service | numint.mnp.divergence.v1, numint.hlr_probe.completed.v1, numint.mnp.churn_anomaly.v1 event shapes |
billing-service | numint.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
- Pre-state: MSISDN
+93701234567owned by Afghan Wireless (seeded). - Lookup via
routing-engine→ response showsmno: afghan-wireless. - Inject MNP file listing this MSISDN as ported to MTN (mock MNO SFTP fixture).
- Trigger
mnp-reconcron on demand. - Verify
numint.mnp.changed.v1event consumed by routing-engine. - Re-lookup → response now shows
mno: mtn-afghanistan,mnpStatus: PORTED_IN. - Assert cache was invalidated (Redis key absent on step 5).
5.2 MNP conflict resolution
- Inject overlapping MNP files from both Afghan Wireless and MTN for the same MSISDN within ±2 days.
- Verify
ReconciliationConflictrow created;NumberRecord.mnpStatusunchanged. - Admin UI shows conflict with deterministic score + LLM triage summary.
- Admin resolves
B_WINS; verifyPortabilityRecordinserted;numint.mnp.changed.v1emitted.
5.3 Public Lookup tenant journey
- Tenant calls
GET /v1/lookup/+93701234567with JWT; 200 returned with typed attribution. billing.metering.recorded.v1emitted with correct SKU.- Tenant exceeds RPS bucket; 429 returned with
Retry-After. - Tenant retries; success.
- Audit query (admin) returns the call log with tenant-salted hash.
5.4 Cache cold start
- Restart all NI pods in
kbl. - Assert no pod becomes ready until
warm-on-deployloads ≥ 80 % of top-N. - Traffic is served from
mzrduring the window; no visible outage. - Verify
numint.cache.refreshed.v1emitted.
6. Chaos tests
| Scenario | Expected behaviour |
|---|---|
| Kill Postgres primary | Hot reads from Redis; writes fail 503; Patroni promotes standby within 90 s; traffic resumes |
| Kill Redis cluster | Cascade falls to PG; latency P95 ≤ 20 ms; cache warms on recovery |
| Kill NATS | Outbox 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 failure | Sibling pod takes over; synthetic probe recovers in < 30 s |
| Network partition kbl↔mzr | Both 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_historyorlookup_auditMUST trigger verifier within 24 h. - Tenant-quota bypass. Attacker using forged
X-Tenant-Idrejected 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-gatewayrejected 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 iffsis E.164. - Hash chain: for any sequence of payloads, verifier outputs
OK; mutating any payload byte at any position MUST produceBROKEN. - 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_hashvalues (per-tenant salt).
9. Performance regression gates
CI load test runs on every main-branch push; fails the build on any:
- P95
ResolveMsisdncache-hit > 5 ms (+ 20 % regression allowance for CI noise). - Aggregate cache hit ratio < 92 % (baseline 95 %).
ResolveMsisdnerror 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
+9379999XXXXXreserved 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.