import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SpamShieldService } from '../src/services/spamshield.service'; import { spamConfig } from '../src/config/spamshield.config'; import { validatePhoneNumber } from '../src/utils/phone-validation'; 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('+14155552671'); 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('+14155552671'); 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('+14155552671'); 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('+14155552671'); 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 E.164 format', async () => { const shortNumber = '123'; const result = service.checkReputation(shortNumber); await expect(result).rejects.toThrow('Invalid E.164 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('+14155552671'); 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('+14155552671'); } 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('+14155552671'); } const metrics = service.getCircuitMetrics(); expect(metrics.hiya.state).toBe('OPEN'); expect(metrics.truecaller.state).toBe('OPEN'); const result = await service.checkReputation('+14155552671'); expect(result.hiyaScore).toBe(0.5); expect(result.truecallerScore).toBe(0.5); }); }); describe('E.164 phone number validation', () => { describe('valid E.164 formats', () => { it('accepts US number in E.164 format', () => { expect(() => validatePhoneNumber('+14155552671')).not.toThrow(); }); it('accepts UK number in E.164 format', () => { expect(() => validatePhoneNumber('+442071234567')).not.toThrow(); }); it('accepts German number in E.164 format', () => { expect(() => validatePhoneNumber('+4930123456789')).not.toThrow(); }); it('accepts Japanese number in E.164 format', () => { expect(() => validatePhoneNumber('+81312345678')).not.toThrow(); }); it('accepts Australian number in E.164 format', () => { expect(() => validatePhoneNumber('+61412345678')).not.toThrow(); }); it('accepts Indian number in E.164 format', () => { expect(() => validatePhoneNumber('+919876543210')).not.toThrow(); }); it('accepts Brazilian number in E.164 format', () => { expect(() => validatePhoneNumber('+5511987654321')).not.toThrow(); }); it('accepts number with spaces and normalizes', () => { const result = validatePhoneNumber('+1 415 555 2671'); expect(result).toBe('+14155552671'); }); it('accepts number with dashes and normalizes', () => { const result = validatePhoneNumber('+1-415-555-2671'); expect(result).toBe('+14155552671'); }); it('accepts number with parentheses and normalizes', () => { const result = validatePhoneNumber('+1 (415) 555-2671'); expect(result).toBe('+14155552671'); }); it('trims whitespace from input', () => { expect(() => validatePhoneNumber(' +14155552671 ')).not.toThrow(); }); }); describe('invalid E.164 formats', () => { it('rejects number without plus sign', () => { expect(() => validatePhoneNumber('14155552671')).toThrow('Invalid E.164 phone number format'); }); it('rejects number with letters', () => { expect(() => validatePhoneNumber('+1415555ABCD')).toThrow('Invalid E.164 phone number format'); }); it('rejects empty string', () => { expect(() => validatePhoneNumber('')).toThrow('Invalid E.164 phone number format'); }); it('rejects too-short number', () => { expect(() => validatePhoneNumber('+123')).toThrow('Invalid E.164 phone number format'); }); it('rejects number exceeding 15 digits', () => { expect(() => validatePhoneNumber('+1234567890123456')).toThrow('Invalid E.164 phone number format'); }); it('rejects number with special characters in middle', () => { expect(() => validatePhoneNumber('+1@4155552671')).toThrow('Invalid E.164 phone number format'); }); it('rejects double plus sign', () => { expect(() => validatePhoneNumber('++14155552671')).toThrow('Invalid E.164 phone number format'); }); it('rejects only whitespace', () => { expect(() => validatePhoneNumber(' ')).toThrow('Invalid E.164 phone number format'); }); it('rejects number starting with zero after plus', () => { expect(() => validatePhoneNumber('+01234567890')).toThrow('Invalid E.164 phone number format'); }); it('rejects negative number format', () => { expect(() => validatePhoneNumber('-14155552671')).toThrow('Invalid E.164 phone number format'); }); }); describe('integration with service methods', () => { it('rejects invalid format in checkReputation', async () => { await expect(service.checkReputation('4155552671')).rejects.toThrow('Invalid E.164 phone number format'); }); it('rejects invalid format in analyzeCall', async () => { const result = service.analyzeCall('4155552671', new Date()); await expect(result).rejects.toThrow('Invalid E.164 phone number format'); }); it('rejects invalid format in recordFeedback', async () => { const result = service.recordFeedback('user123', '4155552671', true); await expect(result).rejects.toThrow('Invalid E.164 phone number format'); }); }); }); });