From e580a693c77f99dd358d01ad51be519fe5626716 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sat, 2 May 2026 01:53:59 -0400 Subject: [PATCH] FRE-4510: Implement feature flag checks for spam classification - Add runtime flag evaluation from FLAG_ environment variables - Add enableCallAnalysis flag check to analyzeCall() and interceptCall() - Add enableFeedbackLoop flag check to recordFeedback() - Add 19 tests for feature flag behavior (checkFeatureFlag, getters, service integration) - Add vitest config and test script to spamshield package --- services/spamshield/package.json | 1 + .../src/config/spamshield.config.ts | 35 +++- .../src/services/spamshield.service.ts | 12 ++ services/spamshield/test/spamshield.test.ts | 172 +++++++++++++++++- services/spamshield/vitest.config.ts | 9 + 5 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 services/spamshield/vitest.config.ts diff --git a/services/spamshield/package.json b/services/spamshield/package.json index 7c4b6da..19c0241 100644 --- a/services/spamshield/package.json +++ b/services/spamshield/package.json @@ -7,6 +7,7 @@ "build": "tsc", "dev": "tsx watch src/index.ts", "lint": "eslint src/", + "test": "vitest run", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/services/spamshield/src/config/spamshield.config.ts b/services/spamshield/src/config/spamshield.config.ts index 7f8bfb4..1e6c599 100644 --- a/services/spamshield/src/config/spamshield.config.ts +++ b/services/spamshield/src/config/spamshield.config.ts @@ -4,7 +4,7 @@ export const spamRateLimits = { PREMIUM: 2000, } as const; -export const spamFeatureFlags = { +export const spamFeatureFlagDefaults = { enableHiyaIntegration: true, enableTruecallerIntegration: true, enableSMSClassification: true, @@ -12,11 +12,42 @@ export const spamFeatureFlags = { enableFeedbackLoop: true, } as const; +type FeatureFlagKey = keyof typeof spamFeatureFlagDefaults; + +export function checkFeatureFlag(flag: FeatureFlagKey): boolean { + const envKey = `FLAG_${flag.toUpperCase()}`; + const envValue = process.env[envKey]; + + if (envValue !== undefined) { + return envValue === 'true' || envValue === '1'; + } + + return spamFeatureFlagDefaults[flag]; +} + +export const spamFeatureFlags = { + get enableHiyaIntegration() { + return checkFeatureFlag('enableHiyaIntegration'); + }, + get enableTruecallerIntegration() { + return checkFeatureFlag('enableTruecallerIntegration'); + }, + get enableSMSClassification() { + return checkFeatureFlag('enableSMSClassification'); + }, + get enableCallAnalysis() { + return checkFeatureFlag('enableCallAnalysis'); + }, + get enableFeedbackLoop() { + return checkFeatureFlag('enableFeedbackLoop'); + }, +} as const; + export const spamConfig = { maxPhoneNumberLength: 20, minPhoneNumberLength: 10, defaultConfidenceThreshold: 0.7, - maxMetadataSize: 1024 * 10, // 10KB + maxMetadataSize: 1024 * 10, circuitBreakerThreshold: 5, circuitBreakerTimeout: 60000, } as const; diff --git a/services/spamshield/src/services/spamshield.service.ts b/services/spamshield/src/services/spamshield.service.ts index 646365f..68933ba 100644 --- a/services/spamshield/src/services/spamshield.service.ts +++ b/services/spamshield/src/services/spamshield.service.ts @@ -197,6 +197,10 @@ export class SpamShieldService { confidence: number; ruleMatches: string[]; }> { + if (!spamFeatureFlags.enableCallAnalysis) { + throw new Error('Call analysis disabled via feature flag'); + } + const validated = this.validatePhoneNumber(phoneNumber); const rules = await this.getActiveRules(); @@ -244,6 +248,10 @@ export class SpamShieldService { isSpam: boolean, label?: string ): Promise { + if (!spamFeatureFlags.enableFeedbackLoop) { + throw new Error('Feedback loop disabled via feature flag'); + } + const validated = this.validatePhoneNumber(phoneNumber); const encrypted = FieldEncryptionService.encrypt(validated); const hash = FieldEncryptionService.hashPhoneNumber(validated); @@ -391,6 +399,10 @@ export class SpamShieldService { // Combined interception methods async interceptCall(call: IncomingCall): Promise { + if (!spamFeatureFlags.enableCallAnalysis) { + throw new Error('Call analysis disabled via feature flag'); + } + const requestId = call.requestId ?? generateRequestId(); const decision = await this.makeRealTimeDecision(call.phoneNumber, { callMetadata: { diff --git a/services/spamshield/test/spamshield.test.ts b/services/spamshield/test/spamshield.test.ts index 57dab71..e3f05ec 100644 --- a/services/spamshield/test/spamshield.test.ts +++ b/services/spamshield/test/spamshield.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SpamShieldService } from '../src/services/spamshield.service'; -import { spamConfig } from '../src/config/spamshield.config'; +import { spamConfig, checkFeatureFlag, spamFeatureFlags, spamFeatureFlagDefaults } from '../src/config/spamshield.config'; import { validatePhoneNumber } from '../src/utils/phone-validation'; const mockFetch = vi.fn(); @@ -306,4 +306,172 @@ describe('SpamShieldService', () => { }); }); }); + + describe('feature flags', () => { + afterEach(() => { + for (const key of ['FLAG_ENABLEHIYAINTEGRATION', 'FLAG_ENABLETRUECALLERINTEGRATION', 'FLAG_ENABLESMSCLASSIFICATION', 'FLAG_ENABLECALLANALYSIS', 'FLAG_ENABLEFEEDBACKLOOP']) { + delete process.env[key]; + } + }); + + describe('checkFeatureFlag', () => { + it('returns default true when env var is unset', () => { + expect(checkFeatureFlag('enableHiyaIntegration')).toBe(true); + expect(checkFeatureFlag('enableCallAnalysis')).toBe(true); + expect(checkFeatureFlag('enableFeedbackLoop')).toBe(true); + }); + + it('returns true when env var is "true"', () => { + process.env.FLAG_ENABLECALLANALYSIS = 'true'; + expect(checkFeatureFlag('enableCallAnalysis')).toBe(true); + }); + + it('returns true when env var is "1"', () => { + process.env.FLAG_ENABLECALLANALYSIS = '1'; + expect(checkFeatureFlag('enableCallAnalysis')).toBe(true); + }); + + it('returns false when env var is "false"', () => { + process.env.FLAG_ENABLECALLANALYSIS = 'false'; + expect(checkFeatureFlag('enableCallAnalysis')).toBe(false); + }); + + it('returns false when env var is "0"', () => { + process.env.FLAG_ENABLECALLANALYSIS = '0'; + expect(checkFeatureFlag('enableCallAnalysis')).toBe(false); + }); + + it('returns false for any non-true/non-1 value', () => { + process.env.FLAG_ENABLECALLANALYSIS = 'yes'; + expect(checkFeatureFlag('enableCallAnalysis')).toBe(false); + }); + }); + + describe('spamFeatureFlags getters', () => { + it('reflects env var overrides for enableCallAnalysis', () => { + expect(spamFeatureFlags.enableCallAnalysis).toBe(true); + process.env.FLAG_ENABLECALLANALYSIS = 'false'; + expect(spamFeatureFlags.enableCallAnalysis).toBe(false); + }); + + it('reflects env var overrides for enableFeedbackLoop', () => { + expect(spamFeatureFlags.enableFeedbackLoop).toBe(true); + process.env.FLAG_ENABLEFEEDBACKLOOP = 'false'; + expect(spamFeatureFlags.enableFeedbackLoop).toBe(false); + }); + + it('reflects env var overrides for enableHiyaIntegration', () => { + expect(spamFeatureFlags.enableHiyaIntegration).toBe(true); + process.env.FLAG_ENABLEHIYAINTEGRATION = 'false'; + expect(spamFeatureFlags.enableHiyaIntegration).toBe(false); + }); + + it('reflects env var overrides for enableTruecallerIntegration', () => { + expect(spamFeatureFlags.enableTruecallerIntegration).toBe(true); + process.env.FLAG_ENABLETRUECALLERINTEGRATION = 'false'; + expect(spamFeatureFlags.enableTruecallerIntegration).toBe(false); + }); + + it('reflects env var overrides for enableSMSClassification', () => { + expect(spamFeatureFlags.enableSMSClassification).toBe(true); + process.env.FLAG_ENABLESMSCLASSIFICATION = 'false'; + expect(spamFeatureFlags.enableSMSClassification).toBe(false); + }); + }); + + describe('enableCallAnalysis flag', () => { + it('throws when call analysis is disabled in analyzeCall', async () => { + process.env.FLAG_ENABLECALLANALYSIS = 'false'; + const result = service.analyzeCall('+14155552671', new Date()); + await expect(result).rejects.toThrow('Call analysis disabled via feature flag'); + }); + + it('throws when call analysis is disabled in interceptCall', async () => { + process.env.FLAG_ENABLECALLANALYSIS = 'false'; + const call = { + callId: 'call-1', + phoneNumber: '+14155552671', + from: '+14155552671', + to: '+14155551234', + startTime: new Date(), + direction: 'inbound' as const, + carrierType: 'twilio' as any, + carrierSid: 'CA123', + }; + const result = service.interceptCall(call); + await expect(result).rejects.toThrow('Call analysis disabled via feature flag'); + }); + + it('passes flag check when call analysis is enabled', async () => { + process.env.FLAG_ENABLECALLANALYSIS = 'true'; + const result = service.analyzeCall('+14155552671', new Date()); + try { + const res = await result; + expect(res).toMatchObject({ + decision: 'ALLOW', + confidence: 0, + ruleMatches: [], + }); + } catch (e) { + expect((e as Error).message).not.toContain('Call analysis disabled'); + } + }); + }); + + describe('enableFeedbackLoop flag', () => { + it('throws when feedback loop is disabled in recordFeedback', async () => { + process.env.FLAG_ENABLEFEEDBACKLOOP = 'false'; + const result = service.recordFeedback('user123', '+14155552671', true); + await expect(result).rejects.toThrow('Feedback loop disabled via feature flag'); + }); + + it('passes flag check when feedback loop is enabled', async () => { + process.env.FLAG_ENABLEFEEDBACKLOOP = 'true'; + const result = service.recordFeedback('user123', '+14155552671', true); + try { + await result; + } catch (e) { + expect((e as Error).message).not.toContain('Feedback loop disabled'); + } + }); + }); + + describe('enableHiyaIntegration flag', () => { + it('excludes Hiya score when integration is disabled', async () => { + service.resetCircuits(); + process.env.FLAG_ENABLEHIYAINTEGRATION = 'false'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ spamProbability: 0.3 }), + } as Response); + const result = await service.checkReputation('+14155552671'); + expect(result.hiyaScore).toBe(undefined); + expect(result.source).toBe('truecaller'); + }); + }); + + describe('enableTruecallerIntegration flag', () => { + it('excludes Truecaller score when integration is disabled', async () => { + service.resetCircuits(); + process.env.FLAG_ENABLETRUECALLERINTEGRATION = 'false'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ spamScore: 0.8 }), + } as Response); + const result = await service.checkReputation('+14155552671'); + expect(result.source).toBe('hiya'); + expect(result.truecallerScore).toBe(undefined); + }); + }); + + describe('enableSMSClassification flag', () => { + it('throws when SMS classification is disabled in classifySms', async () => { + process.env.FLAG_ENABLESMSCLASSIFICATION = 'false'; + const result = service.classifySms('test message'); + await expect(result).rejects.toThrow('SMS Classification disabled via feature flag'); + }); + }); + }); }); diff --git a/services/spamshield/vitest.config.ts b/services/spamshield/vitest.config.ts new file mode 100644 index 0000000..7b21019 --- /dev/null +++ b/services/spamshield/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + }, +});