import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Analytics, EventSchema, EventName } from '../../src/analytics'; import { PostHog } from 'posthog-node'; vi.mock('posthog-node'); describe('Analytics SDK (PostHog Cloud — v2 Post-Review)', () => { let analytics: Analytics; let mockPostHog: vi.Mocked; beforeEach(() => { vi.clearAllMocks(); mockPostHog = new PostHog('phc_test_key', { host: 'https://us.i.posthog.com' }) as any; analytics = new Analytics(mockPostHog); }); // ── Schema Validation (Zod) ────────────────────────────── describe('Event Taxonomy Validation', () => { it('accepts valid account.signup.completed event', () => { const event = { name: EventName.SignupCompleted, tenant_id: 'tenant-123', product: 'route' as const, properties: { method: 'github_sso' }, }; expect(() => EventSchema.parse(event)).not.toThrow(); }); it('rejects events missing tenant_id', () => { const event = { name: EventName.SignupCompleted, product: 'route', properties: { method: 'email' }, }; expect(() => EventSchema.parse(event as any)).toThrow(/tenant_id/); }); it('accepts valid activation event', () => { const event = { name: EventName.FirstDollarSaved, tenant_id: 'tenant-123', product: 'route' as const, properties: { savings_amount: 1.50 }, }; expect(() => EventSchema.parse(event)).not.toThrow(); }); it('accepts valid upgrade event', () => { const event = { name: EventName.UpgradeCompleted, tenant_id: 'tenant-123', product: 'route' as const, properties: { plan: 'pro', mrr_increase: 49 }, }; expect(() => EventSchema.parse(event)).not.toThrow(); }); }); // ── track() Behavior ───────────────────────────────────── describe('track()', () => { it('captures valid events via PostHog client', () => { const result = analytics.track({ name: EventName.SignupCompleted, tenant_id: 'tenant-123', product: 'route', properties: { method: 'email' }, }); expect(result).toBe(true); expect(mockPostHog.capture).toHaveBeenCalledWith( expect.objectContaining({ distinctId: 'tenant-123', event: 'account.signup.completed', properties: expect.objectContaining({ product: 'route', method: 'email', }), }) ); }); it('does NOT include $set in track calls (use identify instead)', () => { analytics.track({ name: EventName.SignupCompleted, tenant_id: 'tenant-123', product: 'route', properties: { method: 'github_sso' }, }); const captureCall = mockPostHog.capture.mock.calls[0][0]; expect(captureCall.properties).not.toHaveProperty('$set'); }); it('does NOT pass timestamp (let PostHog handle it to avoid clock skew)', () => { analytics.track({ name: EventName.SignupCompleted, tenant_id: 'tenant-123', product: 'route', properties: { method: 'email' }, }); const captureCall = mockPostHog.capture.mock.calls[0][0]; expect(captureCall).not.toHaveProperty('timestamp'); }); it('returns false and does NOT call PostHog if base validation fails', () => { const result = analytics.track({ name: 'invalid.event' as any, tenant_id: 'tenant-123', product: 'route', }); expect(result).toBe(false); expect(mockPostHog.capture).not.toHaveBeenCalled(); }); it('returns false if per-event property validation fails (strict schema)', () => { const result = analytics.track({ name: EventName.SignupCompleted, tenant_id: 'tenant-123', product: 'route', properties: { method: 'invalid_method' }, // Not in enum }); expect(result).toBe(false); expect(mockPostHog.capture).not.toHaveBeenCalled(); }); it('rejects unknown properties (strict mode — no PII loophole)', () => { const result = analytics.track({ name: EventName.SignupCompleted, tenant_id: 'tenant-123', product: 'route', properties: { method: 'email', email: 'user@example.com' }, // PII leak attempt }); expect(result).toBe(false); expect(mockPostHog.capture).not.toHaveBeenCalled(); }); it('does NOT flush after each track call (Lambda batching)', () => { analytics.track({ name: EventName.SignupCompleted, tenant_id: 'tenant-123', product: 'route', properties: { method: 'email' }, }); expect(mockPostHog.flushAsync).not.toHaveBeenCalled(); }); }); // ── identify() ─────────────────────────────────────────── describe('identify()', () => { it('calls PostHog identify with tenant_id as distinctId', () => { analytics.identify('tenant-123', { company: 'Acme' }); expect(mockPostHog.identify).toHaveBeenCalledWith( expect.objectContaining({ distinctId: 'tenant-123', properties: expect.objectContaining({ tenant_id: 'tenant-123', company: 'Acme', }), }) ); }); }); // ── flush() ────────────────────────────────────────────── describe('flush()', () => { it('calls flushAsync on the PostHog client', async () => { await analytics.flush(); expect(mockPostHog.flushAsync).toHaveBeenCalledTimes(1); }); }); // ── NoOp Client ────────────────────────────────────────── describe('NoOp Client (missing API key)', () => { it('does not throw when tracking without API key', () => { const noopAnalytics = new Analytics(); // No client, no env var const result = noopAnalytics.track({ name: EventName.SignupCompleted, tenant_id: 'tenant-123', product: 'route', properties: { method: 'email' }, }); expect(result).toBe(true); // NoOp accepts everything silently }); }); // ── Session Replay ─────────────────────────────────────── describe('Security', () => { it('session replay is disabled', () => { expect(analytics.isSessionReplayEnabled).toBe(false); }); }); });