205 lines
6.7 KiB
TypeScript
205 lines
6.7 KiB
TypeScript
|
|
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<PostHog>;
|
||
|
|
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|