import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { MixpanelService, SpamBlockedEvent, MixpanelEventProperties } from '../src/services/mixpanel.service'; import { FieldEncryptionService } from '@shieldai/db'; import crypto from 'crypto'; const mockFetch = vi.fn(); (global as any).fetch = mockFetch; describe('MixpanelService', () => { let service: MixpanelService; beforeEach(() => { service = new MixpanelService({ token: 'test-token', apiHost: 'api.mixpanel.com', enableLogging: false, }); mockFetch.mockReset(); vi.spyOn(console, 'log').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); describe('spamBlocked', () => { it('hashes phoneNumber using SHA-256 before sending to Mixpanel', async () => { const phoneNumber = '+14155552671'; const expectedHash = crypto.createHash('sha256').update(phoneNumber).digest('hex'); mockFetch.mockResolvedValueOnce({ ok: true, status: 200, } as Response); const event: SpamBlockedEvent = { phoneNumber, decision: 'BLOCK', confidence: 0.92, ruleMatches: ['rule-1'], timestamp: new Date('2026-01-01T00:00:00Z'), }; const result = await service.spamBlocked(event); expect(result.phoneNumberHash).toBe(expectedHash); expect(result.phoneNumberHash).not.toBe(phoneNumber); expect(result.phoneNumberHash.length).toBe(64); }); it('uses FieldEncryptionService.hashPhoneNumber for hashing', async () => { const phoneNumber = '+14155552671'; const hashSpy = vi.spyOn(FieldEncryptionService, 'hashPhoneNumber'); mockFetch.mockResolvedValueOnce({ ok: true, status: 200, } as Response); const event: SpamBlockedEvent = { phoneNumber, decision: 'BLOCK', confidence: 0.85, timestamp: new Date(), }; await service.spamBlocked(event); expect(hashSpy).toHaveBeenCalledWith(phoneNumber); hashSpy.mockRestore(); }); it('sends hashed phoneNumber in event properties', async () => { const phoneNumber = '+14155552671'; const expectedHash = FieldEncryptionService.hashPhoneNumber(phoneNumber); mockFetch.mockResolvedValueOnce({ ok: true, status: 200, } as Response); const event: SpamBlockedEvent = { phoneNumber, decision: 'FLAG', confidence: 0.65, ruleMatches: ['rule-2', 'rule-3'], timestamp: new Date('2026-01-01T00:00:00Z'), }; const result = await service.spamBlocked(event); expect(result.$event_name).toBe('spam_blocked'); expect(result.phoneNumberHash).toBe(expectedHash); expect(result.decision).toBe('FLAG'); expect(result.confidence).toBe(0.65); expect(result.ruleMatches).toEqual(['rule-2', 'rule-3']); expect(result.timestamp).toBe('2026-01-01T00:00:00.000Z'); }); it('includes raw phoneNumber only internally, sends hash to Mixpanel API', async () => { const phoneNumber = '+14155552671'; const expectedHash = FieldEncryptionService.hashPhoneNumber(phoneNumber); mockFetch.mockResolvedValueOnce({ ok: true, status: 200, } as Response); const event: SpamBlockedEvent = { phoneNumber, decision: 'BLOCK', confidence: 0.95, timestamp: new Date(), }; await service.spamBlocked(event); const callArgs = mockFetch.mock.calls[0] as any[]; const body = JSON.parse(callArgs[1].body); const trackPayload = JSON.parse(body.data); expect(trackPayload.event).toBe('spam_blocked'); expect(trackPayload.properties.phoneNumberHash).toBe(expectedHash); expect(trackPayload.properties.phoneNumberHash).not.toBe(phoneNumber); }); it('handles different phone numbers with unique hashes', async () => { mockFetch.mockResolvedValue({ ok: true, status: 200, } as Response); const phone1 = '+14155552671'; const phone2 = '+442071234567'; const event1: SpamBlockedEvent = { phoneNumber: phone1, decision: 'BLOCK', confidence: 0.9, timestamp: new Date(), }; const event2: SpamBlockedEvent = { phoneNumber: phone2, decision: 'FLAG', confidence: 0.7, timestamp: new Date(), }; const result1 = await service.spamBlocked(event1); const result2 = await service.spamBlocked(event2); expect(result1.phoneNumberHash).not.toBe(result2.phoneNumberHash); expect(result1.phoneNumberHash).toBe(FieldEncryptionService.hashPhoneNumber(phone1)); expect(result2.phoneNumberHash).toBe(FieldEncryptionService.hashPhoneNumber(phone2)); }); it('produces deterministic hash for same phone number', async () => { mockFetch.mockResolvedValue({ ok: true, status: 200, } as Response); const phoneNumber = '+14155552671'; const event1: SpamBlockedEvent = { phoneNumber, decision: 'BLOCK', confidence: 0.9, timestamp: new Date(), }; const event2: SpamBlockedEvent = { phoneNumber, decision: 'FLAG', confidence: 0.7, timestamp: new Date(), }; const result1 = await service.spamBlocked(event1); const result2 = await service.spamBlocked(event2); expect(result1.phoneNumberHash).toBe(result2.phoneNumberHash); }); it('stores event in internal events array', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, } as Response); const event: SpamBlockedEvent = { phoneNumber: '+14155552671', decision: 'BLOCK', confidence: 0.9, timestamp: new Date(), }; await service.spamBlocked(event); const events = service.getEvents(); expect(events).toHaveLength(1); expect(events[0].phoneNumberHash).toBe(FieldEncryptionService.hashPhoneNumber('+14155552671')); expect(events[0].$event_name).toBe('spam_blocked'); }); it('handles BLOCK and FLAG decisions', async () => { mockFetch.mockResolvedValue({ ok: true, status: 200, } as Response); const blockEvent: SpamBlockedEvent = { phoneNumber: '+14155552671', decision: 'BLOCK', confidence: 0.95, timestamp: new Date(), }; const flagEvent: SpamBlockedEvent = { phoneNumber: '+14155552671', decision: 'FLAG', confidence: 0.6, timestamp: new Date(), }; const blockResult = await service.spamBlocked(blockEvent); const flagResult = await service.spamBlocked(flagEvent); expect(blockResult.decision).toBe('BLOCK'); expect(flagResult.decision).toBe('FLAG'); }); it('gracefully handles Mixpanel API failure', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Internal Server Error', } as Response); const event: SpamBlockedEvent = { phoneNumber: '+14155552671', decision: 'BLOCK', confidence: 0.9, timestamp: new Date(), }; const result = await service.spamBlocked(event); expect(result.phoneNumberHash).toBe(FieldEncryptionService.hashPhoneNumber('+14155552671')); expect(result.status).toBe(500); }); it('gracefully handles Mixpanel API network error', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 429, statusText: 'Too Many Requests', } as Response); const event: SpamBlockedEvent = { phoneNumber: '+14155552671', decision: 'BLOCK', confidence: 0.9, timestamp: new Date(), }; const result = await service.spamBlocked(event); expect(result.phoneNumberHash).toBe(FieldEncryptionService.hashPhoneNumber('+14155552671')); expect(result.status).toBe(429); }); }); describe('hashPhoneNumber consistency', () => { it('hash matches SHA-256 hex digest for known input', () => { const phoneNumber = '+14155552671'; const hash = FieldEncryptionService.hashPhoneNumber(phoneNumber); const expected = crypto.createHash('sha256').update(phoneNumber).digest('hex'); expect(hash).toBe(expected); expect(hash).toBe('cb6880e416769253645cb9c6b8989154bf66a56a77fc14c81fb1019663cbb928'); }); it('hash is 64 characters (SHA-256 hex)', () => { const hash = FieldEncryptionService.hashPhoneNumber('+14155552671'); expect(hash.length).toBe(64); expect(/^[0-9a-f]{64}$/.test(hash)).toBe(true); }); it('different phone numbers produce different hashes', () => { const hash1 = FieldEncryptionService.hashPhoneNumber('+14155552671'); const hash2 = FieldEncryptionService.hashPhoneNumber('+442071234567'); expect(hash1).not.toBe(hash2); }); }); describe('configuration', () => { it('uses custom config when provided', () => { const customService = new MixpanelService({ token: 'custom-token', apiHost: 'custom.api.com', enableLogging: true, }); const config = customService.getConfig(); expect(config.token).toBe('custom-token'); expect(config.apiHost).toBe('custom.api.com'); expect(config.enableLogging).toBe(true); }); it('uses default config when no config provided', () => { const defaultService = new MixpanelService(); const config = defaultService.getConfig(); expect(config.apiHost).toBe('api.mixpanel.com'); }); }); describe('event management', () => { it('clearEvents removes all stored events', async () => { mockFetch.mockResolvedValue({ ok: true, status: 200, } as Response); const event: SpamBlockedEvent = { phoneNumber: '+14155552671', decision: 'BLOCK', confidence: 0.9, timestamp: new Date(), }; await service.spamBlocked(event); expect(service.getEvents()).toHaveLength(1); service.clearEvents(); expect(service.getEvents()).toHaveLength(0); }); }); });