Merge FrenoCorp apps into ShieldAI packages/: - packages/api: merged routes (notifications), middleware (auth, rate-limit, error, logging), config, services (darkwatch, spamshield, voiceprint), tests - packages/web: new SolidJS web app stub - packages/mobile: new SolidJS mobile app stub - packages/shared-db: new Prisma DB package (separate from existing packages/db) - pnpm-workspace.yaml: restored (apps/* removed, already covered by packages/*) Next: reconcile packages/shared-db with packages/db, and fix server.ts correlationRoutes import
145 lines
4.4 KiB
TypeScript
145 lines
4.4 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { SMSClassifierService } from '../services/spamshield/spamshield.service';
|
|
|
|
// Mock shared-db before anything else (Prisma client is not generated in test env)
|
|
vi.mock('@shieldsai/shared-db', () => ({
|
|
prisma: {},
|
|
SpamFeedback: {},
|
|
}));
|
|
|
|
// Mock the feature flags module to control enableMLClassifier
|
|
vi.mock('../services/spamshield/spamshield.config', () => ({
|
|
spamShieldEnv: {
|
|
SPAM_THRESHOLD_AUTO_BLOCK: 0.85,
|
|
SPAM_THRESHOLD_FLAG: 0.6,
|
|
},
|
|
spamFeatureFlags: {
|
|
enableMLClassifier: true,
|
|
},
|
|
SpamDecision: {
|
|
ALLOW: 'allow',
|
|
FLAG: 'flag',
|
|
BLOCK: 'block',
|
|
CHALLENGE: 'challenge',
|
|
},
|
|
SpamLayer: {
|
|
NUMBER_REPUTATION: 'number_reputation',
|
|
CONTENT_CLASSIFICATION: 'content_classification',
|
|
BEHAVIORAL_ANALYSIS: 'behavioral_analysis',
|
|
COMMUNITY_INTELLIGENCE: 'community_intelligence',
|
|
},
|
|
ConfidenceLevel: {
|
|
LOW: 'low',
|
|
MEDIUM: 'medium',
|
|
HIGH: 'high',
|
|
VERY_HIGH: 'very_high',
|
|
},
|
|
spamRateLimits: {},
|
|
}));
|
|
|
|
describe('SMSClassifierService', () => {
|
|
let classifier: SMSClassifierService;
|
|
let initializeCalls: number;
|
|
let initializeDelay: Promise<void>;
|
|
|
|
beforeEach(() => {
|
|
// Re-import after mock to get fresh module state
|
|
initializeCalls = 0;
|
|
initializeDelay = new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
classifier = new SMSClassifierService();
|
|
// Override initialize to track calls and add delay
|
|
classifier.initialize = async () => {
|
|
initializeCalls++;
|
|
await initializeDelay;
|
|
};
|
|
});
|
|
|
|
describe('initialization race condition', () => {
|
|
it('should call initialize only once under concurrent classify calls', async () => {
|
|
const promises = Array.from({ length: 10 }, () =>
|
|
classifier.classify('ACT NOW - Limited offer!'),
|
|
);
|
|
|
|
const results = await Promise.all(promises);
|
|
|
|
expect(initializeCalls).toBe(1);
|
|
expect(results).toHaveLength(10);
|
|
results.forEach(r => {
|
|
expect(r).toHaveProperty('isSpam');
|
|
expect(r).toHaveProperty('confidence');
|
|
expect(r).toHaveProperty('spamFeatures');
|
|
});
|
|
});
|
|
|
|
it('should handle interleaved calls after partial initialization', async () => {
|
|
const batch1 = Array.from({ length: 5 }, () =>
|
|
classifier.classify('First batch message'),
|
|
);
|
|
|
|
await Promise.all(batch1);
|
|
|
|
expect(initializeCalls).toBe(1);
|
|
|
|
const batch2 = Array.from({ length: 5 }, () =>
|
|
classifier.classify('Second batch message'),
|
|
);
|
|
|
|
await Promise.all(batch2);
|
|
|
|
// initialize should still only have been called once
|
|
expect(initializeCalls).toBe(1);
|
|
});
|
|
|
|
it('should return consistent results for same input under concurrency', async () => {
|
|
const text = 'URGENT: Click http://example.com now!';
|
|
const promises = Array.from({ length: 20 }, () =>
|
|
classifier.classify(text),
|
|
);
|
|
|
|
const results = await Promise.all(promises);
|
|
|
|
const firstResult = results[0];
|
|
results.forEach((r, i) => {
|
|
expect(r.isSpam).toBe(firstResult.isSpam);
|
|
expect(r.confidence).toBe(firstResult.confidence);
|
|
expect(r.spamFeatures).toEqual(firstResult.spamFeatures);
|
|
});
|
|
});
|
|
|
|
it('should handle rapid sequential calls without re-initializing', async () => {
|
|
for (let i = 0; i < 50; i++) {
|
|
await classifier.classify(`Message ${i}`);
|
|
}
|
|
|
|
expect(initializeCalls).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('feature extraction', () => {
|
|
it('should detect URL presence', async () => {
|
|
const result = await classifier.classify('Visit www.example.com');
|
|
expect(result.spamFeatures).toContain('url_present');
|
|
});
|
|
|
|
it('should detect urgency keywords', async () => {
|
|
const result = await classifier.classify('Act now! This offer is urgent.');
|
|
expect(result.spamFeatures).toContain('urgency_keyword');
|
|
});
|
|
|
|
it('should detect excessive capitalization', async () => {
|
|
const result = await classifier.classify('BUY THIS NOW!!!');
|
|
expect(result.spamFeatures).toContain('excessive_caps');
|
|
});
|
|
|
|
it('should detect multiple features', async () => {
|
|
const result = await classifier.classify(
|
|
'URGENT: Visit www.example.com NOW!!!',
|
|
);
|
|
expect(result.spamFeatures).toContain('url_present');
|
|
expect(result.spamFeatures).toContain('urgency_keyword');
|
|
expect(result.spamFeatures).toContain('excessive_caps');
|
|
});
|
|
});
|
|
});
|