import { describe, it, expect, vi, beforeEach } from 'vitest'; import jwt from 'jsonwebtoken'; import { ERROR_CODES } from '@insforge/shared-schemas'; const apiHost = 'https://cloud.test.insforge.dev'; const projectId = '77777777-7777-7777-7777-777777777777'; const jwtSecret = 's'.repeat(32); vi.mock('../../src/infra/config/app.config', () => { const c = { cloud: { projectId, apiHost }, app: { jwtSecret }, }; return { config: c, appConfig: c, }; }); interface MockAxiosError extends Error { __isAxiosError: true; response: { status: number; data: { error: string } }; } // Axios mock factory — must be hoisted const axiosGetMock = vi.fn(); const axiosDeleteMock = vi.fn(); const axiosPostMock = vi.fn(); const axiosIsAxiosError = vi.fn((err: unknown) => { return (err as { __isAxiosError?: boolean })?.__isAxiosError === true; }); vi.mock('axios', () => { return { default: { get: axiosGetMock, delete: axiosDeleteMock, post: axiosPostMock, isAxiosError: axiosIsAxiosError, }, }; }); function makeAxiosError(status: number): MockAxiosError { const err = new Error(`Request failed with status code ${status}`) as MockAxiosError; err.__isAxiosError = true; err.response = { status, data: { error: 'test' } }; return err; } // Import after mocks are set up const { PostHogProvider } = await import('../../src/providers/analytics/posthog.provider'); describe('PostHogProvider', () => { beforeEach(() => { vi.clearAllMocks(); // Reset singleton for each test // eslint-disable-next-line @typescript-eslint/no-explicit-any (PostHogProvider as any).instance = undefined; }); describe('getConnection', () => { it('signs JWT with projectId as sub and parses response', async () => { const responseData = { posthogProjectId: '12345', organizationName: 'Org', projectName: 'P', region: 'US', host: 'https://us.posthog.com', apiKey: 'phc_pub', status: 'active', createdAt: '2026-01-01T00:00:00Z', }; axiosGetMock.mockResolvedValueOnce({ data: responseData }); const out = await PostHogProvider.getInstance().getConnection(); expect(out).not.toBeNull(); expect(out!.posthogProjectId).toEqual('12345'); expect(out!.apiKey).toEqual('phc_pub'); // Verify the Authorization header was sent with a valid JWT const callArgs = axiosGetMock.mock.calls[0]; const headers = callArgs[1].headers as Record; expect(headers.Authorization).toMatch(/^Bearer /); const token = headers.Authorization.slice(7); const decoded = jwt.verify(token, jwtSecret) as jwt.JwtPayload; expect(decoded.sub).toEqual(projectId); }); it('returns null on 404', async () => { axiosGetMock.mockRejectedValueOnce(makeAxiosError(404)); const out = await PostHogProvider.getInstance().getConnection(); expect(out).toBeNull(); }); it('throws AppError with UPSTREAM_FAILURE on 502', async () => { axiosGetMock.mockRejectedValueOnce(makeAxiosError(502)); await expect(PostHogProvider.getInstance().getConnection()).rejects.toMatchObject({ statusCode: 502, code: ERROR_CODES.UPSTREAM_FAILURE, }); }); }); describe('getDashboards', () => { it('throws AppError with ANALYTICS_NOT_CONNECTED on 404', async () => { axiosGetMock.mockRejectedValueOnce(makeAxiosError(404)); await expect(PostHogProvider.getInstance().getDashboards()).rejects.toMatchObject({ statusCode: 404, code: ERROR_CODES.ANALYTICS_NOT_CONNECTED, }); }); it('throws AppError with UPSTREAM_FAILURE on network error', async () => { axiosGetMock.mockRejectedValueOnce(new Error('Network Error')); await expect(PostHogProvider.getInstance().getDashboards()).rejects.toMatchObject({ statusCode: 502, code: ERROR_CODES.UPSTREAM_FAILURE, }); }); it('parses dashboards response', async () => { const responseData = { dashboards: [ { id: 1, name: 'Main Dashboard', url: 'https://us.posthog.com/dashboard/1', }, ], count: 1, }; axiosGetMock.mockResolvedValueOnce({ data: responseData }); const out = await PostHogProvider.getInstance().getDashboards(); expect(out.count).toEqual(1); expect(out.dashboards).toHaveLength(1); expect(out.dashboards[0].name).toEqual('Main Dashboard'); }); }); describe('getSummary', () => { it('parses summary response', async () => { axiosGetMock.mockResolvedValueOnce({ data: { todayEvents: 1234, dau24h: 56, totalEvents7d: 8000, topEvents: [{ event: 'pageview', count: 500 }], }, }); const out = await PostHogProvider.getInstance().getSummary(); expect(out.todayEvents).toEqual(1234); expect(out.topEvents[0].event).toEqual('pageview'); }); it('throws AppError with ANALYTICS_NOT_CONNECTED on 404', async () => { axiosGetMock.mockRejectedValueOnce(makeAxiosError(404)); await expect(PostHogProvider.getInstance().getSummary()).rejects.toMatchObject({ statusCode: 404, code: ERROR_CODES.ANALYTICS_NOT_CONNECTED, }); }); it('throws AppError with UPSTREAM_FAILURE on network error', async () => { axiosGetMock.mockRejectedValueOnce(new Error('Network Error')); await expect(PostHogProvider.getInstance().getSummary()).rejects.toMatchObject({ statusCode: 502, code: ERROR_CODES.UPSTREAM_FAILURE, }); }); }); describe('getRecentEvents', () => { it('parses events response with custom limit', async () => { axiosGetMock.mockResolvedValueOnce({ data: { next: null, events: [ { id: 'e1', event: 'pageview', distinctId: 'u1', timestamp: '2026-04-29T10:00:00Z' }, ], }, }); const out = await PostHogProvider.getInstance().getRecentEvents(5); expect(out.events).toHaveLength(1); expect(out.events[0].event).toEqual('pageview'); const call = axiosGetMock.mock.calls[0]; expect(call[1].params.limit).toEqual(5); }); it('default limit is 10', async () => { axiosGetMock.mockResolvedValueOnce({ data: { next: null, events: [] } }); await PostHogProvider.getInstance().getRecentEvents(); const call = axiosGetMock.mock.calls[0]; expect(call[1].params.limit).toEqual(10); }); it('throws AppError with ANALYTICS_NOT_CONNECTED on 404', async () => { axiosGetMock.mockRejectedValueOnce(makeAxiosError(404)); await expect(PostHogProvider.getInstance().getRecentEvents()).rejects.toMatchObject({ statusCode: 404, code: ERROR_CODES.ANALYTICS_NOT_CONNECTED, }); }); }); describe('disconnect', () => { it('issues DELETE to the correct URL', async () => { axiosDeleteMock.mockResolvedValueOnce({ status: 204 }); await PostHogProvider.getInstance().disconnect(); expect(axiosDeleteMock).toHaveBeenCalledOnce(); const callArgs = axiosDeleteMock.mock.calls[0]; expect(callArgs[0]).toEqual(`${apiHost}/projects/v1/${projectId}/posthog/connection`); }); it('throws AppError with UPSTREAM_FAILURE on error', async () => { axiosDeleteMock.mockRejectedValueOnce(makeAxiosError(500)); await expect(PostHogProvider.getInstance().disconnect()).rejects.toMatchObject({ statusCode: 502, code: ERROR_CODES.UPSTREAM_FAILURE, }); }); }); describe('getWebOverview', () => { it('forwards timeframe and parses response', async () => { axiosGetMock.mockResolvedValueOnce({ data: { items: [ { key: 'visitors', value: 100, previous: 80, changeFromPreviousPct: 25, isIncreaseBad: false, }, ], }, }); const out = await PostHogProvider.getInstance().getWebOverview('7d'); expect(out.items).toHaveLength(1); expect(out.items[0].key).toEqual('visitors'); const call = axiosGetMock.mock.calls[0]; expect(call[0]).toEqual(`${apiHost}/projects/v1/${projectId}/posthog/web-overview`); expect(call[1].params.timeframe).toEqual('7d'); }); it('throws AppError with ANALYTICS_NOT_CONNECTED on 404', async () => { axiosGetMock.mockRejectedValueOnce(makeAxiosError(404)); await expect(PostHogProvider.getInstance().getWebOverview('7d')).rejects.toMatchObject({ statusCode: 404, code: ERROR_CODES.ANALYTICS_NOT_CONNECTED, }); }); it('throws AppError with UPSTREAM_FAILURE on network error', async () => { axiosGetMock.mockRejectedValueOnce(new Error('Network Error')); await expect(PostHogProvider.getInstance().getWebOverview('7d')).rejects.toMatchObject({ statusCode: 502, code: ERROR_CODES.UPSTREAM_FAILURE, }); }); }); describe('getWebStats', () => { it('forwards breakdown and timeframe and parses response', async () => { axiosGetMock.mockResolvedValueOnce({ data: { rows: [{ breakdownValue: '/home', visitors: 50, views: 100, uiFillFraction: 1.0 }], }, }); const out = await PostHogProvider.getInstance().getWebStats('Page', '7d'); expect(out.rows).toHaveLength(1); expect(out.rows[0].breakdownValue).toEqual('/home'); const call = axiosGetMock.mock.calls[0]; expect(call[0]).toEqual(`${apiHost}/projects/v1/${projectId}/posthog/web-stats`); expect(call[1].params).toEqual({ breakdown: 'Page', timeframe: '7d' }); }); it('throws AppError with UPSTREAM_FAILURE on network error', async () => { axiosGetMock.mockRejectedValueOnce(new Error('Network Error')); await expect(PostHogProvider.getInstance().getWebStats('Page', '7d')).rejects.toMatchObject({ statusCode: 502, code: ERROR_CODES.UPSTREAM_FAILURE, }); }); }); describe('getTrends', () => { it('forwards metric and timeframe and parses response', async () => { axiosGetMock.mockResolvedValueOnce({ data: { series: [ { date: '2026-05-01', count: 10 }, { date: '2026-05-02', count: 12 }, ], }, }); const out = await PostHogProvider.getInstance().getTrends('visitors', '7d'); expect(out.series).toHaveLength(2); const call = axiosGetMock.mock.calls[0]; expect(call[0]).toEqual(`${apiHost}/projects/v1/${projectId}/posthog/trends`); expect(call[1].params).toEqual({ metric: 'visitors', timeframe: '7d' }); }); it('throws AppError with ANALYTICS_NOT_CONNECTED on 404', async () => { axiosGetMock.mockRejectedValueOnce(makeAxiosError(404)); await expect(PostHogProvider.getInstance().getTrends('visitors', '7d')).rejects.toMatchObject( { statusCode: 404, code: ERROR_CODES.ANALYTICS_NOT_CONNECTED, } ); }); }); describe('getRetention', () => { it('parses retention response', async () => { axiosGetMock.mockResolvedValueOnce({ data: { rows: [ { date: '2026-04-29', label: 'Week 0', values: [{ count: 100 }, { count: 50 }, { count: null }], }, ], }, }); const out = await PostHogProvider.getInstance().getRetention(); expect(out.rows).toHaveLength(1); expect(out.rows[0].values).toHaveLength(3); const call = axiosGetMock.mock.calls[0]; expect(call[0]).toEqual(`${apiHost}/projects/v1/${projectId}/posthog/retention`); }); it('throws AppError with UPSTREAM_FAILURE on network error', async () => { axiosGetMock.mockRejectedValueOnce(new Error('Network Error')); await expect(PostHogProvider.getInstance().getRetention()).rejects.toMatchObject({ statusCode: 502, code: ERROR_CODES.UPSTREAM_FAILURE, }); }); }); describe('getRecordings', () => { it('forwards limit and parses response', async () => { axiosGetMock.mockResolvedValueOnce({ data: { items: [ { id: 'r1', distinctId: 'u1', durationSeconds: 60, startTime: '2026-05-01T00:00:00Z', endTime: '2026-05-01T00:01:00Z', startUrl: '/home', clickCount: 3, consoleErrorCount: 0, }, ], }, }); const out = await PostHogProvider.getInstance().getRecordings(20); expect(out.items).toHaveLength(1); const call = axiosGetMock.mock.calls[0]; expect(call[0]).toEqual(`${apiHost}/projects/v1/${projectId}/posthog/recordings`); expect(call[1].params.limit).toEqual(20); }); it('default limit is 10', async () => { axiosGetMock.mockResolvedValueOnce({ data: { items: [] } }); await PostHogProvider.getInstance().getRecordings(); const call = axiosGetMock.mock.calls[0]; expect(call[1].params.limit).toEqual(10); }); it('throws AppError with ANALYTICS_NOT_CONNECTED on 404', async () => { axiosGetMock.mockRejectedValueOnce(makeAxiosError(404)); await expect(PostHogProvider.getInstance().getRecordings()).rejects.toMatchObject({ statusCode: 404, code: ERROR_CODES.ANALYTICS_NOT_CONNECTED, }); }); }); describe('createRecordingShare', () => { it('POSTs to the correct URL with URL-encoded id and parses response', async () => { axiosPostMock.mockResolvedValueOnce({ data: { embedUrl: 'https://us.posthog.com/embedded/abc' }, }); const out = await PostHogProvider.getInstance().createRecordingShare('rec id/1'); expect(out.embedUrl).toEqual('https://us.posthog.com/embedded/abc'); const call = axiosPostMock.mock.calls[0]; expect(call[0]).toEqual( `${apiHost}/projects/v1/${projectId}/posthog/recordings/rec%20id%2F1/share` ); }); it('rejects non-posthog.com embedUrl as schema violation (502)', async () => { axiosPostMock.mockResolvedValueOnce({ data: { embedUrl: 'https://evil.example.com/embedded/abc' }, }); await expect( PostHogProvider.getInstance().createRecordingShare('rec1') ).rejects.toMatchObject({ statusCode: 502, code: ERROR_CODES.UPSTREAM_FAILURE, }); }); it('throws AppError with ANALYTICS_NOT_CONNECTED on 404', async () => { axiosPostMock.mockRejectedValueOnce(makeAxiosError(404)); await expect( PostHogProvider.getInstance().createRecordingShare('rec1') ).rejects.toMatchObject({ statusCode: 404, code: ERROR_CODES.ANALYTICS_NOT_CONNECTED, }); }); it('throws AppError with UPSTREAM_FAILURE on network error', async () => { axiosPostMock.mockRejectedValueOnce(new Error('Network Error')); await expect( PostHogProvider.getInstance().createRecordingShare('rec1') ).rejects.toMatchObject({ statusCode: 502, code: ERROR_CODES.UPSTREAM_FAILURE, }); }); }); });