FRE-4510: Implement feature flag checks for spam classification
- Add runtime flag evaluation from FLAG_<KEY> 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
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> {
|
||||
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<DecisionResult> {
|
||||
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: {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
9
services/spamshield/vitest.config.ts
Normal file
9
services/spamshield/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user