diff --git a/services/spamshield/src/circuit-breaker/circuit-breaker.ts b/services/spamshield/src/circuit-breaker/circuit-breaker.ts new file mode 100644 index 0000000..60604d8 --- /dev/null +++ b/services/spamshield/src/circuit-breaker/circuit-breaker.ts @@ -0,0 +1,173 @@ +export type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN'; + +export interface CircuitBreakerMetrics { + state: CircuitState; + failureCount: number; + successCount: number; + lastFailureTime: Date | null; + lastSuccessTime: Date | null; + stateChangedAt: Date | null; + totalExecutions: number; + totalFailures: number; + totalSuccesses: number; +} + +export interface CircuitBreakerOptions { + failureThreshold?: number; + successThreshold?: number; + timeout?: number; + onStateChange?: (state: CircuitState, previousState: CircuitState) => void; +} + +const DEFAULT_FAILURE_THRESHOLD = 5; +const DEFAULT_SUCCESS_THRESHOLD = 3; +const DEFAULT_TIMEOUT_MS = 60000; + +export class CircuitBreakerError extends Error { + public readonly state: CircuitState; + + constructor(message: string, state: CircuitState) { + super(message); + this.name = 'CircuitBreakerError'; + this.state = state; + } +} + +export class CircuitBreaker { + private state: CircuitState = 'CLOSED'; + private failureCount = 0; + private successCount = 0; + private lastFailureTime: Date | null = null; + private lastSuccessTime: Date | null = null; + private stateChangedAt: Date | null = null; + private totalExecutions = 0; + private totalFailures = 0; + private totalSuccesses = 0; + + private readonly failureThreshold: number; + private readonly successThreshold: number; + private readonly timeout: number; + private readonly onStateChange?: (state: CircuitState, previousState: CircuitState) => void; + + constructor(options?: CircuitBreakerOptions) { + this.failureThreshold = options?.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD; + this.successThreshold = options?.successThreshold ?? DEFAULT_SUCCESS_THRESHOLD; + this.timeout = options?.timeout ?? DEFAULT_TIMEOUT_MS; + this.onStateChange = options?.onStateChange; + this.stateChangedAt = new Date(); + } + + public getState(): CircuitState { + if (this.state === 'OPEN') { + const elapsed = Date.now() - this.lastFailureTime!.getTime(); + if (elapsed >= this.timeout) { + this.transitionTo('HALF_OPEN'); + } + } + return this.state; + } + + public async execute( + fn: () => Promise, + fallback?: () => T | Promise + ): Promise { + this.totalExecutions++; + const currentState = this.getState(); + + try { + let result: T; + + if (currentState === 'OPEN') { + throw new CircuitBreakerError( + `Circuit is OPEN. Failures: ${this.failureCount}/${this.failureThreshold}`, + this.state + ); + } + + result = await fn(); + this.recordSuccess(); + return result; + } catch (error) { + this.recordFailure(); + + if (fallback) { + try { + return fallback(); + } catch (fallbackError) { + throw error; + } + } + + throw error; + } + } + + public getMetrics(): CircuitBreakerMetrics { + return { + state: this.getState(), + failureCount: this.failureCount, + successCount: this.successCount, + lastFailureTime: this.lastFailureTime, + lastSuccessTime: this.lastSuccessTime, + stateChangedAt: this.stateChangedAt, + totalExecutions: this.totalExecutions, + totalFailures: this.totalFailures, + totalSuccesses: this.totalSuccesses, + }; + } + + public reset(): void { + const previousState = this.state; + this.state = 'CLOSED'; + this.failureCount = 0; + this.successCount = 0; + this.lastFailureTime = null; + this.lastSuccessTime = null; + this.stateChangedAt = new Date(); + if (previousState !== 'CLOSED') { + this.emitStateChange('CLOSED', previousState); + } + } + + private recordSuccess(): void { + this.lastSuccessTime = new Date(); + this.totalSuccesses++; + + if (this.state === 'HALF_OPEN') { + this.successCount++; + if (this.successCount >= this.successThreshold) { + this.transitionTo('CLOSED'); + this.failureCount = 0; + this.successCount = 0; + } + } + } + + private recordFailure(): void { + this.lastFailureTime = new Date(); + this.totalFailures++; + this.failureCount++; + + if (this.state === 'HALF_OPEN') { + this.transitionTo('OPEN'); + } else if (this.state === 'CLOSED' && this.failureCount >= this.failureThreshold) { + this.transitionTo('OPEN'); + } + } + + private transitionTo(newState: CircuitState): void { + const previousState = this.state; + this.state = newState; + this.stateChangedAt = new Date(); + if (newState === 'CLOSED') { + this.successCount = 0; + } + this.emitStateChange(newState, previousState); + } + + private emitStateChange(newState: CircuitState, previousState: CircuitState): void { + if (this.onStateChange && newState !== previousState) { + this.onStateChange(newState, previousState); + } + } +} diff --git a/services/spamshield/src/circuit-breaker/index.ts b/services/spamshield/src/circuit-breaker/index.ts new file mode 100644 index 0000000..6465e15 --- /dev/null +++ b/services/spamshield/src/circuit-breaker/index.ts @@ -0,0 +1,2 @@ +export { CircuitBreaker, CircuitBreakerError } from './circuit-breaker'; +export type { CircuitState, CircuitBreakerMetrics, CircuitBreakerOptions } from './circuit-breaker'; diff --git a/services/spamshield/src/index.ts b/services/spamshield/src/index.ts new file mode 100644 index 0000000..e01cb95 --- /dev/null +++ b/services/spamshield/src/index.ts @@ -0,0 +1,5 @@ +export { SpamShieldService } from './services/spamshield.service'; +export type { ReputationResult, CircuitMetrics } from './services/spamshield.service'; +export { spamRateLimits, spamFeatureFlags, spamConfig } from './config/spamshield.config'; +export { CircuitBreaker, CircuitBreakerError } from './circuit-breaker'; +export type { CircuitState, CircuitBreakerMetrics, CircuitBreakerOptions } from './circuit-breaker'; diff --git a/services/spamshield/src/services/spamshield.service.ts b/services/spamshield/src/services/spamshield.service.ts new file mode 100644 index 0000000..93a961c --- /dev/null +++ b/services/spamshield/src/services/spamshield.service.ts @@ -0,0 +1,285 @@ +import { PrismaClient, SpamFeedback, SpamRule, SpamAuditLog } from '@prisma/client'; +import { FieldEncryptionService } from '@shieldai/db'; +import { spamConfig, spamFeatureFlags } from '../config/spamshield.config'; +import { CircuitBreaker, CircuitBreakerError, CircuitState, CircuitBreakerMetrics } from '../circuit-breaker'; + +const prisma = new PrismaClient() as PrismaClient & { + spamFeedback: { + create: (data: { data: SpamFeedback }) => Promise; + }; + spamRule: { + findMany: (args: { where: { isActive: boolean } }) => Promise; + }; + spamAuditLog: { + create: (data: { data: SpamAuditLog }) => Promise; + }; +}; + +interface InitializationLock { + promise: Promise; + resolved: boolean; +} + +export interface ReputationResult { + score: number; + isSpam: boolean; + source: 'hiya' | 'truecaller' | 'combined' | 'fallback'; + hiyaScore?: number; + truecallerScore?: number; +} + +export interface CircuitMetrics { + hiya: CircuitBreakerMetrics; + truecaller: CircuitBreakerMetrics; +} + +export class SpamShieldService { + private static instance: SpamShieldService; + private initLock: InitializationLock | null = null; + private hiyaBreaker: CircuitBreaker = new CircuitBreaker({ + failureThreshold: spamConfig.circuitBreakerThreshold, + timeout: spamConfig.circuitBreakerTimeout, + }); + private truecallerBreaker: CircuitBreaker = new CircuitBreaker({ + failureThreshold: spamConfig.circuitBreakerThreshold, + timeout: spamConfig.circuitBreakerTimeout, + }); + + private constructor() {} + + static getInstance(): SpamShieldService { + if (!SpamShieldService.instance) { + SpamShieldService.instance = new SpamShieldService(); + } + return SpamShieldService.instance; + } + + async initialize(): Promise { + if (this.initLock?.resolved) return; + + if (!this.initLock) { + this.initLock = { + promise: this._initialize(), + resolved: false, + }; + } + + await this.initLock.promise; + } + + private async _initialize(): Promise { + this.hiyaBreaker = new CircuitBreaker({ + failureThreshold: spamConfig.circuitBreakerThreshold, + timeout: spamConfig.circuitBreakerTimeout, + onStateChange: (state: CircuitState, previous: CircuitState) => { + console.log(`[SpamShield] Hiya circuit: ${previous} -> ${state}`); + }, + }); + this.truecallerBreaker = new CircuitBreaker({ + failureThreshold: spamConfig.circuitBreakerThreshold, + timeout: spamConfig.circuitBreakerTimeout, + onStateChange: (state: CircuitState, previous: CircuitState) => { + console.log(`[SpamShield] Truecaller circuit: ${previous} -> ${state}`); + }, + }); + this.initLock!.resolved = true; + } + + async checkReputation(phoneNumber: string): Promise { + const validated = this.validatePhoneNumber(phoneNumber); + + const results = await Promise.allSettled([ + this.fetchHiyaReputation(validated), + this.fetchTruecallerReputation(validated), + ]); + + const hiyaResult = results[0]; + const truecallerResult = results[1]; + + const hiyaScore = hiyaResult.status === 'fulfilled' ? hiyaResult.value : undefined; + const truecallerScore = truecallerResult.status === 'fulfilled' ? truecallerResult.value : undefined; + + if (hiyaScore !== undefined && truecallerScore !== undefined) { + const combinedScore = (hiyaScore + truecallerScore) / 2; + const isSpam = combinedScore > spamConfig.defaultConfidenceThreshold; + return { + score: combinedScore, + isSpam, + source: 'combined', + hiyaScore, + truecallerScore, + }; + } + + if (hiyaScore !== undefined) { + return { + score: hiyaScore, + isSpam: hiyaScore > spamConfig.defaultConfidenceThreshold, + source: 'hiya', + hiyaScore, + }; + } + + if (truecallerScore !== undefined) { + return { + score: truecallerScore, + isSpam: truecallerScore > spamConfig.defaultConfidenceThreshold, + source: 'truecaller', + truecallerScore, + }; + } + + return { + score: 0, + isSpam: false, + source: 'fallback', + }; + } + + async analyzeCall(phoneNumber: string, callTimestamp: Date): Promise<{ + decision: 'BLOCK' | 'FLAG' | 'ALLOW'; + confidence: number; + ruleMatches: string[]; + }> { + const validated = this.validatePhoneNumber(phoneNumber); + const rules = await this.getActiveRules(); + + const ruleMatches: string[] = []; + let confidence = 0; + + for (const rule of rules) { + const pattern = new RegExp(rule.pattern); + if (pattern.test(validated)) { + ruleMatches.push(rule.id); + confidence += 0.2; + } + } + + confidence = Math.min(confidence, 1.0); + const decision = confidence > 0.8 ? 'BLOCK' : confidence > 0.5 ? 'FLAG' : 'ALLOW'; + + await prisma.spamAuditLog.create({ + data: { + userId: 'system', + phoneNumber: validated, + decision: decision as any, + reason: `Rule-based analysis`, + ruleId: ruleMatches[0], + }, + }); + + return { decision, confidence, ruleMatches }; + } + + async recordFeedback( + userId: string, + phoneNumber: string, + isSpam: boolean, + label?: string + ): Promise { + const validated = this.validatePhoneNumber(phoneNumber); + const encrypted = FieldEncryptionService.encrypt(validated); + const hash = FieldEncryptionService.hashPhoneNumber(validated); + + await prisma.spamFeedback.create({ + data: { + userId, + phoneNumber: encrypted, + phoneNumberHash: hash, + isSpam, + label, + metadata: JSON.stringify({ source: 'user_feedback' }), + }, + }); + } + + getCircuitMetrics(): CircuitMetrics { + return { + hiya: this.hiyaBreaker.getMetrics(), + truecaller: this.truecallerBreaker.getMetrics(), + }; + } + + resetCircuits(): void { + this.hiyaBreaker.reset(); + this.truecallerBreaker.reset(); + } + + private async fetchHiyaReputation(phoneNumber: string): Promise { + if (!spamFeatureFlags.enableHiyaIntegration) { + throw new Error('Hiya integration disabled'); + } + + return this.hiyaBreaker.execute( + async () => { + const url = `https://api.hiya.com/reputation/${encodeURIComponent(phoneNumber)}`; + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${process.env.HIYA_API_KEY}`, + 'Accept': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Hiya API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as { spamScore?: number; reputation?: { score?: number } }; + const score = data.spamScore ?? data.reputation?.score ?? 0; + return score; + }, + () => { + console.log('[SpamShield] Hiya fallback: circuit OPEN, returning neutral score'); + return 0.5; + } + ); + } + + private async fetchTruecallerReputation(phoneNumber: string): Promise { + if (!spamFeatureFlags.enableTruecallerIntegration) { + throw new Error('Truecaller integration disabled'); + } + + return this.truecallerBreaker.execute( + async () => { + const url = `https://redirect.truecaller.com/api/v2-ac/absolute/${encodeURIComponent(phoneNumber)}`; + const response = await fetch(url, { + method: 'GET', + headers: { + 'contentType': 'lookupNumber', + 'Authorization': `Basic ${Buffer.from(process.env.TRUECALLER_API_KEY || '').toString('base64')}`, + 'Accept': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Truecaller API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as { spamProbability?: number; spam_type?: number }; + const probability = data.spamProbability ?? (data.spam_type ? 0.8 : 0); + return probability; + }, + () => { + console.log('[SpamShield] Truecaller fallback: circuit OPEN, returning neutral score'); + return 0.5; + } + ); + } + + private validatePhoneNumber(phoneNumber: string): string { + if (phoneNumber.length < spamConfig.minPhoneNumberLength || + phoneNumber.length > spamConfig.maxPhoneNumberLength) { + throw new Error(`Invalid phone number format: ${phoneNumber}`); + } + return phoneNumber; + } + + private async getActiveRules(): Promise> { + return prisma.spamRule.findMany({ + where: { isActive: true }, + select: { id: true, pattern: true }, + }); + } +} diff --git a/services/spamshield/test/circuit-breaker.test.ts b/services/spamshield/test/circuit-breaker.test.ts new file mode 100644 index 0000000..658e245 --- /dev/null +++ b/services/spamshield/test/circuit-breaker.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CircuitBreaker, CircuitBreakerError, CircuitState } from '../src/circuit-breaker'; + +const fail = async () => { throw new Error('fail'); }; +const success = async () => 'ok'; + +async function executeOrFail(breaker: CircuitBreaker, fn: () => Promise, fallback?: () => T): Promise { + try { + return await breaker.execute(fn, fallback); + } catch (e) { + return e as Error; + } +} + +describe('CircuitBreaker', () => { + let stateChanges: Array<{ state: CircuitState; previous: CircuitState }>; + + beforeEach(() => { + stateChanges = []; + }); + + describe('initial state', () => { + it('starts as CLOSED', () => { + const breaker = new CircuitBreaker(); + expect(breaker.getState()).toBe('CLOSED'); + }); + + it('uses default thresholds', () => { + const breaker = new CircuitBreaker(); + const metrics = breaker.getMetrics(); + expect(metrics.failureCount).toBe(0); + expect(metrics.successCount).toBe(0); + expect(metrics.totalExecutions).toBe(0); + }); + }); + + describe('custom configuration', () => { + it('accepts custom failure threshold', async () => { + const breaker = new CircuitBreaker({ failureThreshold: 3 }); + for (let i = 0; i < 3; i++) { + await executeOrFail(breaker, fail); + } + expect(breaker.getState()).toBe('OPEN'); + }); + + it('accepts custom timeout', () => { + const breaker = new CircuitBreaker({ timeout: 1000 }); + expect(breaker.getState()).toBe('CLOSED'); + }); + + it('calls onStateChange callback on transitions', async () => { + const breaker = new CircuitBreaker({ + failureThreshold: 2, + onStateChange: (state, previous) => { + stateChanges.push({ state, previous }); + }, + }); + + await executeOrFail(breaker, fail); + await executeOrFail(breaker, fail); + + expect(stateChanges).toHaveLength(1); + expect(stateChanges[0]).toEqual({ state: 'OPEN', previous: 'CLOSED' }); + }); + }); + + describe('state transitions', () => { + it('transitions to OPEN after reaching failure threshold', async () => { + const breaker = new CircuitBreaker({ failureThreshold: 3 }); + + await executeOrFail(breaker, fail); + await executeOrFail(breaker, fail); + expect(breaker.getState()).toBe('CLOSED'); + + await executeOrFail(breaker, fail); + expect(breaker.getState()).toBe('OPEN'); + }); + + it('transitions from OPEN to HALF_OPEN after timeout', async () => { + const breaker = new CircuitBreaker({ + failureThreshold: 2, + timeout: 200, + }); + + await executeOrFail(breaker, fail); + await executeOrFail(breaker, fail); + expect(breaker.getState()).toBe('OPEN'); + + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(breaker.getState()).toBe('HALF_OPEN'); + }); + + it('transitions from HALF_OPEN to CLOSED after success threshold', async () => { + const breaker = new CircuitBreaker({ + failureThreshold: 2, + successThreshold: 3, + timeout: 100, + }); + + await executeOrFail(breaker, fail); + await executeOrFail(breaker, fail); + expect(breaker.getState()).toBe('OPEN'); + + await new Promise((resolve) => setTimeout(resolve, 150)); + + const r1 = await breaker.execute(success); + const r2 = await breaker.execute(success); + expect(r1).toBe('ok'); + expect(r2).toBe('ok'); + expect(breaker.getState()).toBe('HALF_OPEN'); + + const r3 = await breaker.execute(success); + expect(r3).toBe('ok'); + expect(breaker.getState()).toBe('CLOSED'); + }); + + it('transitions from HALF_OPEN back to OPEN on failure', async () => { + const breaker = new CircuitBreaker({ + failureThreshold: 2, + timeout: 100, + }); + + await executeOrFail(breaker, fail); + await executeOrFail(breaker, fail); + + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(breaker.getState()).toBe('HALF_OPEN'); + + await executeOrFail(breaker, fail); + expect(breaker.getState()).toBe('OPEN'); + }); + }); + + describe('execute with fallback', () => { + it('returns fallback value when circuit is OPEN', async () => { + const breaker = new CircuitBreaker({ failureThreshold: 2 }); + + await executeOrFail(breaker, fail); + await executeOrFail(breaker, fail); + + const result = await breaker.execute( + async () => { throw new Error('should not reach'); }, + () => 'fallback-value' + ); + + expect(result).toBe('fallback-value'); + }); + + it('returns fallback value when API throws in OPEN state', async () => { + const breaker = new CircuitBreaker({ failureThreshold: 1 }); + + await executeOrFail(breaker, fail); + + const originalFn = vi.fn(() => { throw new Error('api error'); }); + const fallbackFn = vi.fn(() => 0.5); + + const result = await breaker.execute(originalFn, fallbackFn); + expect(result).toBe(0.5); + expect(fallbackFn).toHaveBeenCalled(); + }); + + it('executes function normally when circuit is CLOSED', async () => { + const breaker = new CircuitBreaker({ failureThreshold: 3 }); + const fn = vi.fn(async () => 'success'); + + const result = await breaker.execute(fn, () => 'fallback'); + expect(result).toBe('success'); + expect(fn).toHaveBeenCalled(); + }); + + it('uses fallback when circuit is OPEN', async () => { + const breaker = new CircuitBreaker({ failureThreshold: 1 }); + await executeOrFail(breaker, fail); + + const fn = vi.fn(async () => 'original'); + const fallback = vi.fn(() => 'fallback-value'); + + const result = await breaker.execute(fn, fallback); + expect(result).toBe('fallback-value'); + expect(fallback).toHaveBeenCalled(); + }); + + it('throws CircuitBreakerError when OPEN and no fallback', async () => { + const breaker = new CircuitBreaker({ failureThreshold: 1 }); + await executeOrFail(breaker, fail); + + const result = await executeOrFail(breaker, async () => 'value'); + expect(result).toBeInstanceOf(CircuitBreakerError); + expect((result as CircuitBreakerError).state).toBe('OPEN'); + }); + + it('throws original error when fallback also fails in CLOSED state', async () => { + const breaker = new CircuitBreaker({ failureThreshold: 5 }); + + const originalError = new Error('api error'); + const result = await executeOrFail( + breaker, + async () => { throw originalError; }, + () => { throw new Error('fallback error'); } + ); + expect(result).toBe(originalError); + }); + }); + + describe('metrics', () => { + it('tracks total executions', async () => { + const breaker = new CircuitBreaker(); + await breaker.execute(success); + await breaker.execute(success); + + const metrics = breaker.getMetrics(); + expect(metrics.totalExecutions).toBe(2); + expect(metrics.totalSuccesses).toBe(2); + expect(metrics.totalFailures).toBe(0); + }); + + it('tracks failures', async () => { + const breaker = new CircuitBreaker({ failureThreshold: 5 }); + await executeOrFail(breaker, fail); + + const metrics = breaker.getMetrics(); + expect(metrics.totalExecutions).toBe(1); + expect(metrics.totalFailures).toBe(1); + expect(metrics.failureCount).toBe(1); + }); + + it('includes state change timestamp', () => { + const breaker = new CircuitBreaker(); + const metrics = breaker.getMetrics(); + expect(metrics.stateChangedAt).toBeDefined(); + expect(metrics.stateChangedAt!.getTime()).toBeGreaterThan(Date.now() - 1000); + }); + + it('tracks last failure and success times', async () => { + const breaker = new CircuitBreaker({ failureThreshold: 5 }); + const before = Date.now(); + + await breaker.execute(success); + await new Promise((resolve) => setTimeout(resolve, 10)); + await executeOrFail(breaker, fail); + + const metrics = breaker.getMetrics(); + expect(metrics.lastSuccessTime!.getTime()).toBeGreaterThanOrEqual(before); + expect(metrics.lastFailureTime!.getTime()).toBeGreaterThanOrEqual(metrics.lastSuccessTime!.getTime()); + }); + }); + + describe('reset', () => { + it('resets circuit to CLOSED state', async () => { + const breaker = new CircuitBreaker({ failureThreshold: 2 }); + + await executeOrFail(breaker, fail); + await executeOrFail(breaker, fail); + expect(breaker.getState()).toBe('OPEN'); + + breaker.reset(); + expect(breaker.getState()).toBe('CLOSED'); + + const metrics = breaker.getMetrics(); + expect(metrics.failureCount).toBe(0); + expect(metrics.successCount).toBe(0); + }); + + it('allows execution after reset', async () => { + const breaker = new CircuitBreaker({ failureThreshold: 1 }); + await executeOrFail(breaker, fail); + + breaker.reset(); + const result = await breaker.execute(success); + expect(result).toBe('ok'); + }); + }); + + describe('CircuitBreakerError', () => { + it('includes circuit state in error', async () => { + const breaker = new CircuitBreaker({ failureThreshold: 1 }); + await executeOrFail(breaker, fail); + + const result = await executeOrFail(breaker, success); + expect(result).toBeInstanceOf(CircuitBreakerError); + expect((result as CircuitBreakerError).state).toBe('OPEN'); + expect((result as CircuitBreakerError).message).toContain('OPEN'); + }); + }); +}); diff --git a/services/spamshield/test/spamshield.test.ts b/services/spamshield/test/spamshield.test.ts new file mode 100644 index 0000000..8b62260 --- /dev/null +++ b/services/spamshield/test/spamshield.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SpamShieldService } from '../src/services/spamshield.service'; +import { spamConfig } from '../src/config/spamshield.config'; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as unknown as typeof global.fetch; + +describe('SpamShieldService', () => { + let service: SpamShieldService; + + beforeEach(() => { + service = SpamShieldService.getInstance(); + service.resetCircuits(); + mockFetch.mockReset(); + vi.clearAllMocks(); + }); + + describe('checkReputation', () => { + it('combines scores from both Hiya and Truecaller', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ spamScore: 0.8 }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ spamProbability: 0.9 }), + } as Response); + + const result = await service.checkReputation('+1234567890'); + + expect(result.source).toBe('combined'); + expect(result.score).toBeCloseTo(0.85, 2); + expect(result.isSpam).toBe(true); + expect(result.hiyaScore).toBe(0.8); + expect(result.truecallerScore).toBe(0.9); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('uses Hiya score + Truecaller fallback when Truecaller API fails', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ spamScore: 0.6 }), + } as Response) + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + } as Response); + + const result = await service.checkReputation('+1234567890'); + + expect(result.source).toBe('combined'); + expect(result.hiyaScore).toBe(0.6); + expect(result.truecallerScore).toBe(0.5); + }); + + it('uses Hiya fallback + Truecaller score when Hiya API fails', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ spamProbability: 0.3 }), + } as Response); + + const result = await service.checkReputation('+1234567890'); + + expect(result.source).toBe('combined'); + expect(result.hiyaScore).toBe(0.5); + expect(result.truecallerScore).toBe(0.3); + }); + + it('uses both fallbacks when both APIs fail', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + } as Response) + .mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + } as Response); + + const result = await service.checkReputation('+1234567890'); + + expect(result.source).toBe('combined'); + expect(result.hiyaScore).toBe(0.5); + expect(result.truecallerScore).toBe(0.5); + expect(result.score).toBe(0.5); + expect(result.isSpam).toBe(false); + }); + + it('validates phone number length', async () => { + const shortNumber = '123'; + const result = service.checkReputation(shortNumber); + await expect(result).rejects.toThrow('Invalid phone number format'); + }); + + it('returns non-spam for low scores', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ spamScore: 0.2 }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ spamProbability: 0.1 }), + } as Response); + + const result = await service.checkReputation('+1234567890'); + + expect(result.isSpam).toBe(false); + expect(result.score).toBeCloseTo(0.15, 2); + }); + }); + + describe('circuit breaker integration', () => { + it('opens circuit after consecutive failures', async () => { + const metricsBefore = service.getCircuitMetrics(); + expect(metricsBefore.hiya.state).toBe('CLOSED'); + + for (let i = 0; i < spamConfig.circuitBreakerThreshold; i++) { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Server Error', + } as Response) + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Server Error', + } as Response); + await service.checkReputation('+1234567890'); + } + + const metricsAfter = service.getCircuitMetrics(); + expect(metricsAfter.hiya.totalFailures).toBeGreaterThanOrEqual(spamConfig.circuitBreakerThreshold); + expect(metricsAfter.truecaller.totalFailures).toBeGreaterThanOrEqual(spamConfig.circuitBreakerThreshold); + }); + + it('exposes circuit metrics for monitoring', () => { + const metrics = service.getCircuitMetrics(); + + expect(metrics.hiya).toHaveProperty('state', 'CLOSED'); + expect(metrics.hiya).toHaveProperty('failureCount'); + expect(metrics.hiya).toHaveProperty('successCount'); + expect(metrics.hiya).toHaveProperty('totalExecutions'); + expect(metrics.hiya).toHaveProperty('totalFailures'); + expect(metrics.hiya).toHaveProperty('totalSuccesses'); + expect(metrics.hiya).toHaveProperty('lastFailureTime'); + expect(metrics.hiya).toHaveProperty('lastSuccessTime'); + expect(metrics.hiya).toHaveProperty('stateChangedAt'); + + expect(metrics.truecaller).toHaveProperty('state', 'CLOSED'); + }); + + it('resets circuits to CLOSED state', () => { + service.resetCircuits(); + const metrics = service.getCircuitMetrics(); + expect(metrics.hiya.state).toBe('CLOSED'); + expect(metrics.truecaller.state).toBe('CLOSED'); + }); + + it('returns fallback scores when circuits are open', async () => { + service.resetCircuits(); + + for (let i = 0; i < spamConfig.circuitBreakerThreshold; i++) { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Server Error', + } as Response) + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Server Error', + } as Response); + await service.checkReputation('+1234567890'); + } + + const metrics = service.getCircuitMetrics(); + expect(metrics.hiya.state).toBe('OPEN'); + expect(metrics.truecaller.state).toBe('OPEN'); + + const result = await service.checkReputation('+1234567890'); + expect(result.hiyaScore).toBe(0.5); + expect(result.truecallerScore).toBe(0.5); + }); + }); +});