import { expect, test } from './fixtures'; import { UNKNOWN_UUID } from './helpers/constants'; import { dateRange, dateRangeIso } from './helpers/dates'; import { uniqueName } from './helpers/entities'; import type { SeedState } from './seed/state'; /** Body shared by every report run endpoint (filters + parameters date windows). */ function runBody(seed: SeedState, type: string, parameters: Record = {}) { return { websiteId: seed.website.id, type, filters: { ...dateRange(seed), timezone: 'UTC', unit: 'day' }, parameters: { ...dateRangeIso(seed), ...parameters }, }; } test.describe('Saved reports', () => { test.describe.configure({ mode: 'serial' }); let reportId = ''; test.afterAll(async ({ admin }) => { if (reportId) { await admin.del(`/api/reports/${reportId}`); } }); test('POST /api/reports creates a report', async ({ admin, seed }) => { const name = uniqueName('report'); const response = await admin.post('/api/reports', { websiteId: seed.website.id, type: 'funnel', name, description: 'created by the api suite', parameters: { window: 60, steps: [] }, }); expect(response.status).toBe(200); expect(response.body).toMatchObject({ id: expect.any(String), userId: seed.admin.id, websiteId: seed.website.id, type: 'funnel', name, description: 'created by the api suite', parameters: { window: 60, steps: [] }, }); reportId = response.body.id; }); test('POST /api/reports validates the body', async ({ admin, seed }) => { const badType = await admin.post('/api/reports', { websiteId: seed.website.id, type: 'nope', name: 'x', parameters: {}, }); const missingParameters = await admin.post('/api/reports', { websiteId: seed.website.id, type: 'goal', name: 'x', }); const longName = await admin.post('/api/reports', { websiteId: seed.website.id, type: 'goal', name: 'x'.repeat(201), parameters: {}, }); expect(badType.status).toBe(400); expect(missingParameters.status).toBe(400); expect(longName.status).toBe(400); }); test('POST /api/reports requires update access to the website', async ({ viewer, user, api, seed, }) => { const body = { websiteId: seed.website.id, type: 'goal', name: 'x', parameters: {} }; const denied = await viewer.post('/api/reports', body); const otherOwner = await user.post('/api/reports', body); const anonymous = await api.post('/api/reports', body); const unknown = await user.post('/api/reports', { ...body, websiteId: UNKNOWN_UUID }); expect(denied.status).toBe(401); expect(otherOwner.status).toBe(401); expect(anonymous.status).toBe(401); expect(unknown.status).toBe(401); }); test('GET /api/reports lists reports for a website', async ({ admin, seed }) => { const response = await admin.get('/api/reports', { params: { websiteId: seed.website.id } }); const byType = await admin.get('/api/reports', { params: { websiteId: seed.website.id, type: 'funnel' }, }); const otherType = await admin.get('/api/reports', { params: { websiteId: seed.website.id, type: 'retention' }, }); expect(response.status).toBe(200); expect(response.body).toMatchObject({ data: expect.any(Array), count: expect.any(Number), page: 1, pageSize: expect.any(Number), }); expect(response.body.data.map((r: any) => r.id)).toContain(reportId); expect(byType.status).toBe(200); expect(byType.body.data.map((r: any) => r.id)).toContain(reportId); expect(byType.body.data.every((r: any) => r.type === 'funnel')).toBe(true); expect(otherType.body.data.map((r: any) => r.id)).not.toContain(reportId); }); test('GET /api/reports validates websiteId and checks access', async ({ admin, viewer, share, seed, }) => { const missing = await admin.get('/api/reports'); const invalid = await admin.get('/api/reports', { params: { websiteId: 'nope' } }); const badType = await admin.get('/api/reports', { params: { websiteId: seed.website.id, type: 'nope' }, }); const denied = await viewer.get('/api/reports', { params: { websiteId: seed.website.id } }); const shared = await (await share()).get('/api/reports', { params: { websiteId: seed.website.id }, }); const sharedByType = await (await share()).get('/api/reports', { params: { websiteId: seed.website.id, type: 'funnel' }, }); expect(missing.status).toBe(400); expect(invalid.status).toBe(400); expect(badType.status).toBe(400); expect(denied.status).toBe(401); // Listing every type requires a real user; a share token can only list a single section. expect(shared.status).toBe(401); expect(sharedByType.status).toBe(200); expect(sharedByType.body.data.map((r: any) => r.id)).toContain(reportId); }); test('GET /api/websites/{websiteId}/reports lists the website reports', async ({ admin, viewer, seed, }) => { const response = await admin.get(`/api/websites/${seed.website.id}/reports`); const byType = await admin.get(`/api/websites/${seed.website.id}/reports`, { params: { type: 'retention' }, }); const badType = await admin.get(`/api/websites/${seed.website.id}/reports`, { params: { type: 'nope' }, }); const denied = await viewer.get(`/api/websites/${seed.website.id}/reports`); expect(response.status).toBe(200); expect(response.body).toMatchObject({ data: expect.any(Array), count: expect.any(Number) }); expect(response.body.data.map((r: any) => r.id)).toContain(reportId); expect(byType.status).toBe(200); expect(byType.body.data.map((r: any) => r.id)).not.toContain(reportId); expect(badType.status).toBe(400); expect(denied.status).toBe(401); }); test('GET /api/reports/{reportId} returns a report', async ({ admin, viewer, api }) => { const response = await admin.get(`/api/reports/${reportId}`); const denied = await viewer.get(`/api/reports/${reportId}`); const anonymous = await api.get(`/api/reports/${reportId}`); const unknown = await admin.get(`/api/reports/${UNKNOWN_UUID}`); expect(response.status).toBe(200); expect(response.body).toMatchObject({ id: reportId, type: 'funnel', parameters: { window: 60 }, createdAt: expect.any(String), }); expect(denied.status).toBe(401); expect(anonymous.status).toBe(401); expect(unknown.status).toBe(404); }); test('POST /api/reports/{reportId} updates a report', async ({ admin, viewer, seed }) => { const name = uniqueName('renamed'); const body = { websiteId: seed.website.id, type: 'funnel', name, description: 'updated', parameters: { window: 30, steps: [{ type: 'path', value: '/' }] }, }; const response = await admin.post(`/api/reports/${reportId}`, body); const invalid = await admin.post(`/api/reports/${reportId}`, { name }); const denied = await viewer.post(`/api/reports/${reportId}`, body); const unknown = await admin.post(`/api/reports/${UNKNOWN_UUID}`, body); expect(response.status).toBe(200); expect(response.body).toMatchObject({ id: reportId, name, description: 'updated', parameters: { window: 30 }, }); expect(invalid.status).toBe(400); expect(denied.status).toBe(401); expect(unknown.status).toBe(404); }); test('DELETE /api/reports/{reportId} deletes a report', async ({ admin, viewer }) => { const denied = await viewer.del(`/api/reports/${reportId}`); const response = await admin.del(`/api/reports/${reportId}`); const gone = await admin.get(`/api/reports/${reportId}`); const unknown = await admin.del(`/api/reports/${UNKNOWN_UUID}`); expect(denied.status).toBe(401); expect(response.status).toBe(200); expect(response.body).toEqual({ ok: true }); expect(gone.status).toBe(404); expect(unknown.status).toBe(404); reportId = ''; }); }); test.describe('Report runs', () => { test('POST /api/reports/attribution returns attribution by channel', async ({ admin, share, viewer, seed, }) => { const response = await admin.post( '/api/reports/attribution', runBody(seed, 'attribution', { model: 'first-click', type: 'path', step: '/pricing' }), ); const lastClick = await admin.post( '/api/reports/attribution', runBody(seed, 'attribution', { model: 'last-click', type: 'event', step: 'signup' }), ); const shared = await (await share()).post( '/api/reports/attribution', runBody(seed, 'attribution', { model: 'first-click', type: 'path', step: '/pricing' }), ); const invalid = await admin.post( '/api/reports/attribution', runBody(seed, 'attribution', { model: 'nope', type: 'path', step: '/' }), ); const denied = await viewer.post( '/api/reports/attribution', runBody(seed, 'attribution', { model: 'first-click', type: 'path', step: '/' }), ); expect(response.status).toBe(200); expect(response.body).toMatchObject({ referrer: expect.any(Array), paidAds: expect.any(Array), utm_source: expect.any(Array), utm_medium: expect.any(Array), utm_campaign: expect.any(Array), utm_content: expect.any(Array), utm_term: expect.any(Array), total: { pageviews: expect.any(Number), visitors: expect.any(Number), visits: expect.any(Number), }, }); expect(response.body.total.visitors).toBeGreaterThan(0); expect(lastClick.status).toBe(200); expect(shared.status).toBe(200); expect(invalid.status).toBe(400); expect(denied.status).toBe(401); }); test('POST /api/reports/breakdown returns rows grouped by field', async ({ admin, share, viewer, seed, }) => { const response = await admin.post( '/api/reports/breakdown', runBody(seed, 'breakdown', { fields: ['path'] }), ); const multi = await admin.post( '/api/reports/breakdown', runBody(seed, 'breakdown', { fields: ['browser', 'os'] }), ); const shared = await (await share()).post( '/api/reports/breakdown', runBody(seed, 'breakdown', { fields: ['path'] }), ); const invalid = await admin.post( '/api/reports/breakdown', runBody(seed, 'breakdown', { fields: ['nope'] }), ); const denied = await viewer.post( '/api/reports/breakdown', runBody(seed, 'breakdown', { fields: ['path'] }), ); expect(response.status).toBe(200); expect(Array.isArray(response.body)).toBe(true); // Aggregates come back as numeric strings on Postgres and numbers on ClickHouse. expect(response.body[0]).toMatchObject({ path: expect.any(String), views: expect.anything(), visitors: expect.anything(), visits: expect.anything(), bounces: expect.anything(), totaltime: expect.anything(), }); expect(Number(response.body[0].views)).toBeGreaterThan(0); expect(response.body.map((r: any) => r.path)).toContain('/pricing'); expect(multi.status).toBe(200); expect(multi.body[0]).toMatchObject({ browser: expect.any(String), os: expect.any(String) }); expect(shared.status).toBe(200); expect(invalid.status).toBe(400); expect(denied.status).toBe(401); }); test('POST /api/reports/funnel returns conversion per step', async ({ admin, share, viewer, seed, }) => { const parameters = { window: 60, steps: [ { type: 'path', value: '/' }, { type: 'path', value: '/pricing' }, { type: 'path', value: '/blog/hello-world' }, ], }; const response = await admin.post('/api/reports/funnel', runBody(seed, 'funnel', parameters)); const shared = await (await share()).post( '/api/reports/funnel', runBody(seed, 'funnel', parameters), ); const tooFewSteps = await admin.post( '/api/reports/funnel', runBody(seed, 'funnel', { window: 60, steps: [{ type: 'path', value: '/' }] }), ); const denied = await viewer.post('/api/reports/funnel', runBody(seed, 'funnel', parameters)); expect(response.status).toBe(200); expect(response.body).toHaveLength(3); expect(response.body[0]).toMatchObject({ type: 'path', value: '/', visitors: expect.any(Number), previous: expect.any(Number), dropped: expect.any(Number), remaining: expect.any(Number), }); expect(response.body[1]).toMatchObject({ value: '/pricing', dropoff: expect.any(Number) }); expect(response.body[0].visitors).toBeGreaterThan(0); expect(response.body[1].visitors).toBeGreaterThan(0); expect(response.body[1].visitors).toBeLessThanOrEqual(response.body[0].visitors); expect(response.body[2].visitors).toBeLessThanOrEqual(response.body[1].visitors); expect(shared.status).toBe(200); expect(tooFewSteps.status).toBe(400); expect(denied.status).toBe(401); }); test('POST /api/reports/goal returns goal completion', async ({ admin, share, viewer, seed }) => { const response = await admin.post( '/api/reports/goal', runBody(seed, 'goal', { type: 'path', value: '/' }), ); const event = await admin.post( '/api/reports/goal', runBody(seed, 'goal', { type: 'event', value: 'signup' }), ); const shared = await (await share()).post( '/api/reports/goal', runBody(seed, 'goal', { type: 'path', value: '/' }), ); const invalid = await admin.post('/api/reports/goal', runBody(seed, 'goal', { type: 'path' })); const denied = await viewer.post( '/api/reports/goal', runBody(seed, 'goal', { type: 'path', value: '/' }), ); expect(response.status).toBe(200); expect(response.body).toMatchObject({ num: expect.any(Number), total: expect.any(Number) }); expect(response.body.num).toBeGreaterThan(0); expect(response.body.total).toBeGreaterThanOrEqual(response.body.num); expect(event.status).toBe(200); expect(event.body.num).toBeGreaterThan(0); expect(shared.status).toBe(200); expect(invalid.status).toBe(400); expect(denied.status).toBe(401); }); test('POST /api/reports/heatmap returns click and scroll data', async ({ admin, share, viewer, seed, }) => { const response = await admin.post( '/api/reports/heatmap', runBody(seed, 'heatmap', { urlPath: '/', mode: 'click' }), ); const scroll = await admin.post( '/api/reports/heatmap', runBody(seed, 'heatmap', { urlPath: '/', mode: 'scroll' }), ); const invalid = await admin.post( '/api/reports/heatmap', runBody(seed, 'heatmap', { urlPath: '/', mode: 'nope' }), ); const shared = await (await share()).post( '/api/reports/heatmap', runBody(seed, 'heatmap', { urlPath: '/' }), ); const denied = await viewer.post( '/api/reports/heatmap', runBody(seed, 'heatmap', { urlPath: '/' }), ); expect(response.status).toBe(200); expect(response.body).toMatchObject({ mode: 'click', pages: expect.any(Array), points: expect.any(Array), scroll: { buckets: expect.any(Array), totalSessions: expect.any(Number) }, }); expect(response.body.pages.map((p: any) => p.urlPath ?? p.url ?? p.path)).toContain('/'); expect(scroll.status).toBe(200); expect(scroll.body.mode).toBe('scroll'); expect(invalid.status).toBe(400); // Heatmaps require a logged in user; share tokens are rejected. expect(shared.status).toBe(401); expect(denied.status).toBe(401); }); test('POST /api/reports/journey returns paths between steps', async ({ admin, share, viewer, seed, }) => { const response = await admin.post( '/api/reports/journey', runBody(seed, 'journey', { steps: 3 }), ); const anchored = await admin.post( '/api/reports/journey', runBody(seed, 'journey', { steps: 3, startStep: '/' }), ); const shared = await (await share()).post( '/api/reports/journey', runBody(seed, 'journey', { steps: 3 }), ); const invalid = await admin.post( '/api/reports/journey', runBody(seed, 'journey', { steps: 1 }), ); const denied = await viewer.post( '/api/reports/journey', runBody(seed, 'journey', { steps: 3 }), ); expect(response.status).toBe(200); expect(Array.isArray(response.body)).toBe(true); expect(response.body.length).toBeGreaterThan(0); expect(response.body[0]).toMatchObject({ items: expect.any(Array), count: expect.any(Number) }); expect(response.body[0].items.length).toBeGreaterThan(0); expect(anchored.status).toBe(200); expect(anchored.body.length).toBeGreaterThan(0); expect(anchored.body.every((r: any) => r.items[0] === '/')).toBe(true); expect(shared.status).toBe(200); expect(invalid.status).toBe(400); expect(denied.status).toBe(401); }); test('POST /api/reports/performance returns web vitals', async ({ admin, share, viewer, seed, }) => { const response = await admin.post( '/api/reports/performance', runBody(seed, 'performance', { unit: 'day', timezone: 'UTC' }), ); const metric = await admin.post( '/api/reports/performance', runBody(seed, 'performance', { metric: 'lcp' }), ); const shared = await (await share()).post( '/api/reports/performance', runBody(seed, 'performance'), ); const invalid = await admin.post( '/api/reports/performance', runBody(seed, 'performance', { metric: 'nope' }), ); const denied = await viewer.post('/api/reports/performance', runBody(seed, 'performance')); expect(response.status).toBe(200); expect(response.body).toMatchObject({ chart: expect.any(Array), summary: { count: expect.any(Number) }, pages: expect.any(Array), pageTitles: expect.any(Array), devices: expect.any(Array), browsers: expect.any(Array), }); expect(response.body.summary.count).toBeGreaterThan(0); expect(metric.status).toBe(200); expect(shared.status).toBe(200); expect(invalid.status).toBe(400); expect(denied.status).toBe(401); }); test('POST /api/reports/retention returns cohorts by day', async ({ admin, share, viewer, seed, }) => { const response = await admin.post( '/api/reports/retention', runBody(seed, 'retention', { timezone: 'UTC' }), ); const shared = await (await share()).post('/api/reports/retention', runBody(seed, 'retention')); const denied = await viewer.post('/api/reports/retention', runBody(seed, 'retention')); expect(response.status).toBe(200); expect(Array.isArray(response.body)).toBe(true); expect(response.body.length).toBeGreaterThan(0); expect(response.body[0]).toMatchObject({ date: expect.any(String), day: expect.any(Number), visitors: expect.any(Number), returnVisitors: expect.any(Number), percentage: expect.any(Number), }); expect(shared.status).toBe(200); expect(denied.status).toBe(401); }); test('POST /api/reports/revenue returns revenue totals', async ({ admin, share, viewer, seed, }) => { const response = await admin.post( '/api/reports/revenue', runBody(seed, 'revenue', { currency: seed.data.currency }), ); const shared = await (await share()).post( '/api/reports/revenue', runBody(seed, 'revenue', { currency: seed.data.currency }), ); const missingCurrency = await admin.post('/api/reports/revenue', runBody(seed, 'revenue')); const denied = await viewer.post( '/api/reports/revenue', runBody(seed, 'revenue', { currency: seed.data.currency }), ); expect(response.status).toBe(200); expect(response.body).toMatchObject({ chart: expect.any(Array), total: { sum: expect.anything(), count: expect.anything(), average: expect.anything(), unique_count: expect.anything(), }, country: expect.any(Array), region: expect.any(Array), referrer: expect.any(Array), channel: expect.any(Array), }); expect(Number(response.body.total.sum)).toBeGreaterThan(0); expect(Number(response.body.total.count)).toBeGreaterThan(0); expect(shared.status).toBe(200); expect(missingCurrency.status).toBe(400); expect(denied.status).toBe(401); }); test('POST /api/reports/utm returns views per utm parameter', async ({ admin, share, viewer, seed, }) => { const response = await admin.post('/api/reports/utm', runBody(seed, 'utm')); const shared = await (await share()).post('/api/reports/utm', runBody(seed, 'utm')); const denied = await viewer.post('/api/reports/utm', runBody(seed, 'utm')); expect(response.status).toBe(200); expect(response.body).toMatchObject({ utm_source: expect.any(Array), utm_medium: expect.any(Array), utm_campaign: expect.any(Array), utm_term: expect.any(Array), utm_content: expect.any(Array), }); expect(response.body.utm_source[0]).toMatchObject({ utm: expect.any(String), views: expect.any(Number), }); expect(response.body.utm_source.map((r: any) => r.utm)).toContain('newsletter'); expect(response.body.utm_medium.map((r: any) => r.utm)).toContain('email'); expect(response.body.utm_campaign.map((r: any) => r.utm)).toContain('launch'); expect(shared.status).toBe(200); expect(denied.status).toBe(401); }); test('POST /api/reports/{type} validates the discriminator and envelope', async ({ admin, user, api, seed, }) => { const wrongType = await admin.post('/api/reports/utm', runBody(seed, 'goal')); const missingFilters = await admin.post('/api/reports/utm', { websiteId: seed.website.id, type: 'utm', parameters: dateRangeIso(seed), }); const missingParameters = await admin.post('/api/reports/utm', { websiteId: seed.website.id, type: 'utm', filters: dateRange(seed), }); const anonymous = await api.post('/api/reports/utm', runBody(seed, 'utm')); const unknownWebsite = await user.post('/api/reports/utm', { ...runBody(seed, 'utm'), websiteId: UNKNOWN_UUID, }); expect(wrongType.status).toBe(400); expect(missingFilters.status).toBe(400); expect(missingParameters.status).toBe(400); expect(anonymous.status).toBe(401); expect(unknownWebsite.status).toBe(401); }); });