Add circuit breaker for Hiya/Truecaller external APIs (FRE-4508)
- Implement CircuitBreaker class with CLOSED/OPEN/HALF_OPEN states - Configurable failure threshold, success threshold, and timeout - Fallback behavior when circuit opens (returns neutral 0.5 score) - State change callbacks for monitoring and logging - Comprehensive metrics tracking (executions, failures, successes, timestamps) - Update SpamShieldService to use circuit breakers for both Hiya and Truecaller - Add parallel API calls with graceful degradation - Export circuit breaker types and service interfaces - 32 unit tests covering circuit transitions, fallback, and service integration Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
199
services/spamshield/test/spamshield.test.ts
Normal file
199
services/spamshield/test/spamshield.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user