454 lines
15 KiB
TypeScript
454 lines
15 KiB
TypeScript
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<string, string>;
|
|
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,
|
|
});
|
|
});
|
|
});
|
|
});
|