Add MixpanelService with hashed phoneNumber in spamBlocked() (FRE-4519)

Create MixpanelService that uses FieldEncryptionService.hashPhoneNumber()
to SHA-256 hash phone numbers before sending to Mixpanel analytics.

- Implement spamBlocked() method with phone number hashing
- Add 16 unit tests verifying hash correctness and API behavior
- Export service from package index

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-02 09:21:42 -04:00
parent b01b79d02a
commit b6b0f86d73
3 changed files with 457 additions and 0 deletions

View File

@@ -0,0 +1,340 @@
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);
});
});
});