Files
Kordant/packages/shared-analytics/src/__tests__/mixpanel.service.test.ts
Michael Freno 06ca3ec0cf Fix Mixpanel analytics review findings FRE-5281
P0: Fix validation bypass - validated properties now override raw properties
P1: Add unit tests for shared-analytics package (3 test files)
P1: Refactor spamshield to use shared-analytics, deprecate duplicate
P2: Normalize phone numbers to E.164 before hashing
P2: Add graceful error handling for missing env vars in config
P3: Add singleton pattern to MixpanelService
P3: Include timestamp in validated properties schema
2026-05-17 15:37:21 -04:00

181 lines
5.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('MixpanelService', () => {
let MixpanelService: any;
let mixpanelService: any;
let mockAnalytics: any;
beforeEach(() => {
vi.stubEnv('MIXPANEL_TOKEN', 'test-token');
vi.stubEnv('GA4_MEASUREMENT_ID', 'G-TEST123');
mockAnalytics = {
track: vi.fn(),
identify: vi.fn(),
group: vi.fn(),
flush: vi.fn(),
};
vi.doMock('@segment/analytics-node', () => ({
Analytics: vi.fn(() => mockAnalytics),
}));
vi.resetModules();
});
afterEach(async () => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
const config = await import('../config/analytics.config');
if (config.analyticsEnv) {
vi.clearAllMocks();
}
});
it('implements singleton pattern via getInstance', async () => {
const mod = await import('../services/mixpanel.service');
MixpanelService = mod.MixpanelService;
const instance1 = MixpanelService.getInstance();
const instance2 = MixpanelService.getInstance();
expect(instance1).toBe(instance2);
});
it('exports singleton instance as mixpanelService', async () => {
const mod = await import('../services/mixpanel.service');
mixpanelService = mod.mixpanelService;
const instance = MixpanelService.getInstance();
expect(mixpanelService).toBe(instance);
});
it('validated properties override raw properties (P0 fix)', async () => {
const mod = await import('../services/mixpanel.service');
mixpanelService = mod.mixpanelService;
await mixpanelService.track(
mod.EventType.USER_SIGNED_UP,
'user-123',
{
platform: 'web',
version: 'malicious-value',
}
);
const trackCall = mockAnalytics.track.mock.calls[0];
const properties = trackCall[0].properties;
expect(properties.platform).toBe('web');
expect(properties.version).toBe('malicious-value');
expect(properties.timestamp).toBeDefined();
});
it('adds timestamp to all tracked events', async () => {
const mod = await import('../services/mixpanel.service');
mixpanelService = mod.mixpanelService;
await mixpanelService.track(mod.EventType.USER_SIGNED_UP, 'user-123', {});
const trackCall = mockAnalytics.track.mock.calls[0];
expect(trackCall[0].properties.timestamp).toBeInstanceOf(Date);
});
it('tracks user signup event correctly', async () => {
const mod = await import('../services/mixpanel.service');
mixpanelService = mod.mixpanelService;
await mixpanelService.userSignedUp('user-123', 'pro', 'google');
const trackCall = mockAnalytics.track.mock.calls[0];
expect(trackCall[0].event).toBe('user_signed_up');
expect(trackCall[0].distinctId).toBe('user-123');
expect(trackCall[0].properties.plan).toBe('pro');
expect(trackCall[0].properties.referrer).toBe('google');
});
it('tracks user upgrade event correctly', async () => {
const mod = await import('../services/mixpanel.service');
mixpanelService = mod.mixpanelService;
await mixpanelService.userUpgraded('user-123', 'free', 'pro', 29.99);
const trackCall = mockAnalytics.track.mock.calls[0];
expect(trackCall[0].event).toBe('user_upgraded');
expect(trackCall[0].properties.fromTier).toBe('free');
expect(trackCall[0].properties.toTier).toBe('pro');
expect(trackCall[0].properties.mrr).toBe(29.99);
});
it('tracks spam blocked event with hashed phone number', async () => {
const mod = await import('../services/mixpanel.service');
mixpanelService = mod.mixpanelService;
await mixpanelService.spamBlocked('user-123', '+14155552671', 0.95, 'ml');
const trackCall = mockAnalytics.track.mock.calls[0];
expect(trackCall[0].event).toBe('spam_blocked');
expect(trackCall[0].properties.phoneNumber).toMatch(/^sha256_/);
expect(trackCall[0].properties.confidence).toBe(0.95);
expect(trackCall[0].properties.method).toBe('ml');
});
it('does not send raw phone number in spam blocked events', async () => {
const mod = await import('../services/mixpanel.service');
mixpanelService = mod.mixpanelService;
const rawPhone = '+14155552671';
await mixpanelService.spamBlocked('user-123', rawPhone, 0.95, 'ml');
const trackCall = mockAnalytics.track.mock.calls[0];
expect(trackCall[0].properties.phoneNumber).not.toBe(rawPhone);
});
it('calls identify correctly', async () => {
const mod = await import('../services/mixpanel.service');
mixpanelService = mod.mixpanelService;
await mixpanelService.identify('user-123', { name: 'John Doe', plan: 'pro' });
expect(mockAnalytics.identify).toHaveBeenCalledWith({
distinctId: 'user-123',
traits: { name: 'John Doe', plan: 'pro' },
});
});
it('calls group correctly', async () => {
const mod = await import('../services/mixpanel.service');
mixpanelService = mod.mixpanelService;
await mixpanelService.group('org-123', 'organization', { name: 'Acme Corp' });
expect(mockAnalytics.group).toHaveBeenCalledWith({
groupKey: 'organization',
groupId: 'org-123',
traits: { name: 'Acme Corp' },
});
});
it('calls flush correctly', async () => {
const mod = await import('../services/mixpanel.service');
mixpanelService = mod.mixpanelService;
await mixpanelService.flush();
expect(mockAnalytics.flush).toHaveBeenCalled();
});
it('handles exposure detected event', async () => {
const mod = await import('../services/mixpanel.service');
mixpanelService = mod.mixpanelService;
await mixpanelService.exposureDetected('user-123', 'breach', 'high', 'haveibeenpwned');
const trackCall = mockAnalytics.track.mock.calls[0];
expect(trackCall[0].event).toBe('exposure_detected');
expect(trackCall[0].properties.exposureType).toBe('breach');
expect(trackCall[0].properties.severity).toBe('high');
expect(trackCall[0].properties.source).toBe('haveibeenpwned');
});
});