Files
ShieldAI/services/spamshield/test/spamshield.test.ts
Senior Engineer 76d431e1ec Add E.164 input validation for phone numbers (FRE-4506)
- Extract phone validation to reusable utility (src/utils/phone-validation.ts)
- Use libphonenumber-js for strict E.164 format validation
- Normalize accepted numbers to canonical E.164 format
- Add 22 comprehensive validation tests covering valid/invalid formats
- Update existing tests to use valid E.164 test numbers
- All 56 tests passing

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 21:22:23 -04:00

310 lines
11 KiB
TypeScript

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');
});
});
});
});