Files
umami/tests/api/analytics.spec.ts

470 lines
17 KiB
TypeScript

import { expect, test } from './fixtures';
import { EVENT_TYPE, UNKNOWN_UUID } from './helpers/constants';
import { dateRange, TIMEZONE } from './helpers/dates';
const point = { x: expect.any(String), y: expect.any(Number) };
test.describe('Analytics', () => {
test.describe.configure({ mode: 'serial' });
test('GET /api/websites/{websiteId}/stats returns totals for the range', async ({
admin,
share,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/stats`;
const response = await admin.get(path, { params: dateRange(seed) });
const shared = await (await share()).get(path, { params: dateRange(seed) });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
pageviews: expect.anything(),
visitors: expect.anything(),
visits: expect.anything(),
bounces: expect.anything(),
totaltime: expect.anything(),
comparison: expect.any(Object),
});
// Other specs may send extra pageviews to the seeded website.
expect(Number(response.body.pageviews)).toBeGreaterThanOrEqual(seed.data.expectedPageviews);
expect(Number(response.body.visitors)).toBeGreaterThan(0);
expect(Number(response.body.visits)).toBeGreaterThan(0);
expect(shared.status).toBe(200);
});
test('GET /api/websites/{websiteId}/stats applies filters', async ({ admin, seed }) => {
const path = `/api/websites/${seed.website.id}/stats`;
const all = await admin.get(path, { params: dateRange(seed) });
const filtered = await admin.get(path, {
params: dateRange(seed, { path: '/pricing' }),
});
const none = await admin.get(path, {
params: dateRange(seed, { path: '/zzz-no-such-page' }),
});
expect(filtered.status).toBe(200);
expect(Number(filtered.body.pageviews)).toBeGreaterThan(0);
expect(Number(filtered.body.pageviews)).toBeLessThan(Number(all.body.pageviews));
expect(Number(none.body.pageviews)).toBe(0);
});
test('GET /api/websites/{websiteId}/stats validates params and permissions', async ({
admin,
api,
viewer,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/stats`;
const missingRange = await admin.get(path);
const anonymous = await api.get(path, { params: dateRange(seed) });
const denied = await viewer.get(path, { params: dateRange(seed) });
const unknown = await viewer.get(`/api/websites/${UNKNOWN_UUID}/stats`, {
params: dateRange(seed),
});
expect(missingRange.status).toBe(400);
expect(anonymous.status).toBe(401);
expect(denied.status).toBe(401);
expect(unknown.status).toBe(401);
});
test('GET /api/websites/{websiteId}/pageviews returns pageview and session series', async ({
admin,
share,
viewer,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/pageviews`;
const params = dateRange(seed, { unit: 'day', timezone: TIMEZONE });
const response = await admin.get(path, { params });
const shared = await (await share()).get(path, { params });
const denied = await viewer.get(path, { params });
expect(response.status).toBe(200);
expect(response.body).toEqual({
pageviews: expect.any(Array),
sessions: expect.any(Array),
});
expect(response.body.pageviews.length).toBeGreaterThan(0);
expect(response.body.pageviews[0]).toMatchObject(point);
expect(response.body.sessions[0]).toMatchObject(point);
const total = response.body.pageviews.reduce((sum: number, p: any) => sum + p.y, 0);
expect(total).toBeGreaterThanOrEqual(seed.data.expectedPageviews);
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
});
test('GET /api/websites/{websiteId}/pageviews supports compare and validates unit', async ({
admin,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/pageviews`;
const compare = await admin.get(path, {
params: dateRange(seed, { unit: 'day', timezone: TIMEZONE, compare: 'prev' }),
});
const invalidUnit = await admin.get(path, {
params: dateRange(seed, { unit: 'fortnight', timezone: TIMEZONE }),
});
expect(compare.status).toBe(200);
expect(compare.body.compare).toEqual({
pageviews: expect.any(Array),
sessions: expect.any(Array),
startDate: expect.any(String),
endDate: expect.any(String),
});
expect(invalidUnit.status).toBe(400);
});
test('GET /api/websites/{websiteId}/metrics returns session, pageview and event metrics', async ({
admin,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/metrics`;
const browser = await admin.get(path, { params: dateRange(seed, { type: 'browser' }) });
const url = await admin.get(path, { params: dateRange(seed, { type: 'path' }) });
const event = await admin.get(path, { params: dateRange(seed, { type: 'event' }) });
const channel = await admin.get(path, { params: dateRange(seed, { type: 'channel' }) });
const stats = await admin.get(`/api/websites/${seed.website.id}/stats`, {
params: dateRange(seed),
});
expect(browser.status).toBe(200);
expect(browser.body.length).toBeGreaterThan(0);
expect(browser.body[0]).toEqual(point);
expect(url.status).toBe(200);
const paths = url.body.map((row: any) => row.x);
for (const page of seed.data.pages) {
expect(paths).toContain(page);
}
// `y` is the number of distinct sessions per path, so it never exceeds pageviews.
const total = url.body.reduce((sum: number, row: any) => sum + row.y, 0);
expect(total).toBeGreaterThan(0);
expect(total).toBeLessThanOrEqual(Number(stats.body.pageviews));
expect(event.status).toBe(200);
const events = event.body.map((row: any) => row.x);
for (const name of seed.data.eventNames) {
expect(events).toContain(name);
}
expect(channel.status).toBe(200);
expect(Array.isArray(channel.body)).toBe(true);
});
test('GET /api/websites/{websiteId}/metrics supports search, limit and offset', async ({
admin,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/metrics`;
const search = await admin.get(path, {
params: dateRange(seed, { type: 'path', search: 'pric' }),
});
const limited = await admin.get(path, {
params: dateRange(seed, { type: 'path', limit: 2 }),
});
const offset = await admin.get(path, {
params: dateRange(seed, { type: 'path', limit: 2, offset: 2 }),
});
expect(search.status).toBe(200);
expect(search.body.length).toBeGreaterThan(0);
for (const row of search.body) {
expect(row.x).toContain('pric');
}
expect(limited.body).toHaveLength(2);
expect(offset.body).toHaveLength(2);
expect(offset.body[0].x).not.toBe(limited.body[0].x);
});
test('GET /api/websites/{websiteId}/metrics validates type and permissions', async ({
admin,
share,
viewer,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/metrics`;
const missingType = await admin.get(path, { params: dateRange(seed) });
const invalidType = await admin.get(path, { params: dateRange(seed, { type: 'bogus' }) });
const shared = await (await share()).get(path, { params: dateRange(seed, { type: 'os' }) });
const denied = await viewer.get(path, { params: dateRange(seed, { type: 'os' }) });
expect(missingType.status).toBe(400);
expect(invalidType.status).toBe(400);
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
});
test('GET /api/websites/{websiteId}/metrics/expanded returns rows with breakdown totals', async ({
admin,
viewer,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/metrics/expanded`;
const response = await admin.get(path, { params: dateRange(seed, { type: 'path' }) });
const invalid = await admin.get(path, { params: dateRange(seed, { type: 'bogus' }) });
const denied = await viewer.get(path, { params: dateRange(seed, { type: 'path' }) });
expect(response.status).toBe(200);
expect(response.body.length).toBeGreaterThan(0);
// Expanded rows label the dimension `name` rather than `x`.
expect(response.body[0]).toMatchObject({
name: expect.any(String),
visitors: expect.anything(),
visits: expect.anything(),
pageviews: expect.anything(),
bounces: expect.anything(),
totaltime: expect.anything(),
});
expect(response.body.map((row: any) => row.name)).toContain('/');
const pageviews = response.body.reduce(
(sum: number, row: any) => sum + Number(row.pageviews),
0,
);
expect(pageviews).toBeGreaterThanOrEqual(seed.data.expectedPageviews);
expect(invalid.status).toBe(400);
expect(denied.status).toBe(401);
});
test('GET /api/websites/{websiteId}/values returns distinct values for a field', async ({
admin,
viewer,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/values`;
const paths = await admin.get(path, { params: dateRange(seed, { type: 'path' }) });
const events = await admin.get(path, { params: dateRange(seed, { type: 'event' }) });
const distinct = await admin.get(path, {
params: dateRange(seed, { type: 'distinctId' }),
});
const searched = await admin.get(path, {
params: dateRange(seed, { type: 'path', search: 'docs' }),
});
const denied = await viewer.get(path, { params: dateRange(seed, { type: 'path' }) });
expect(paths.status).toBe(200);
expect(paths.body[0]).toEqual({ value: expect.any(String), count: expect.anything() });
const values = paths.body.map((row: any) => row.value);
for (const page of seed.data.pages) {
expect(values).toContain(page);
}
expect(events.status).toBe(200);
const names = events.body.map((row: any) => row.value);
for (const name of seed.data.eventNames) {
expect(names).toContain(name);
}
expect(distinct.status).toBe(200);
const ids = distinct.body.map((row: any) => row.value);
for (const id of seed.data.distinctIds) {
expect(ids).toContain(id);
}
expect(searched.status).toBe(200);
expect(searched.body.map((row: any) => row.value)).toContain('/docs');
expect(denied.status).toBe(401);
});
test('GET /api/websites/{websiteId}/values rejects unsupported types', async ({
admin,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/values`;
const missing = await admin.get(path, { params: dateRange(seed) });
const fullPath = await admin.get(path, { params: dateRange(seed, { type: 'fullPath' }) });
const channel = await admin.get(path, { params: dateRange(seed, { type: 'channel' }) });
expect(missing.status).toBe(400);
expect(fullPath.status).toBe(400);
expect(channel.status).toBe(400);
});
test('GET /api/websites/{websiteId}/active returns the active visitor count', async ({
admin,
share,
viewer,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/active`;
const response = await admin.get(path);
const shared = await (await share()).get(path);
const denied = await viewer.get(path);
expect(response.status).toBe(200);
expect(response.body).toEqual({ visitors: expect.any(Number) });
expect(response.body.visitors).toBeGreaterThanOrEqual(0);
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
});
test('GET /api/websites/{websiteId}/events lists events with paging', async ({ admin, seed }) => {
const path = `/api/websites/${seed.website.id}/events`;
const response = await admin.get(path, { params: dateRange(seed, { pageSize: 5 }) });
const second = await admin.get(path, {
params: dateRange(seed, { pageSize: 5, page: 2 }),
});
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
data: expect.any(Array),
count: expect.any(Number),
page: 1,
pageSize: 5,
});
expect(response.body.data).toHaveLength(5);
expect(response.body.count).toBeGreaterThanOrEqual(seed.data.expectedPageviews);
expect(response.body.data[0]).toMatchObject({
id: expect.any(String),
sessionId: expect.any(String),
createdAt: expect.any(String),
urlPath: expect.any(String),
eventType: expect.any(Number),
});
expect(response.body.data[0]).toHaveProperty('eventName');
expect(response.body.data[0]).toHaveProperty('hasData');
expect(second.status).toBe(200);
expect(second.body.page).toBe(2);
expect(second.body.data[0].id).not.toBe(response.body.data[0].id);
});
test('GET /api/websites/{websiteId}/events filters custom events and validates', async ({
admin,
api,
viewer,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/events`;
const custom = await admin.get(path, {
params: dateRange(seed, { eventType: EVENT_TYPE.customEvent, pageSize: 100 }),
});
const search = await admin.get(path, {
params: dateRange(seed, { eventType: EVENT_TYPE.customEvent, search: 'purchase' }),
});
const missingRange = await admin.get(path);
const anonymous = await api.get(path, { params: dateRange(seed) });
const denied = await viewer.get(path, { params: dateRange(seed) });
expect(custom.status).toBe(200);
expect(custom.body.data.length).toBeGreaterThan(0);
for (const row of custom.body.data) {
expect(row.eventType).toBe(EVENT_TYPE.customEvent);
expect(row.eventName).toEqual(expect.any(String));
}
// Other specs send their own custom events, so only check the seeded names are present.
const names = new Set(custom.body.data.map((row: any) => row.eventName));
for (const name of seed.data.eventNames) {
expect(names).toContain(name);
}
expect(search.status).toBe(200);
expect(search.body.data.length).toBeGreaterThan(0);
for (const row of search.body.data) {
expect(row.eventName).toBe('purchase');
}
expect(missingRange.status).toBe(400);
expect(anonymous.status).toBe(401);
expect(denied.status).toBe(401);
});
test('GET /api/websites/{websiteId}/events/series returns per-event time series', async ({
admin,
share,
viewer,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/events/series`;
const params = dateRange(seed, { unit: 'day', timezone: TIMEZONE });
const response = await admin.get(path, { params });
const limited = await admin.get(path, { params: { ...params, limit: 1 } });
const missingTimezone = await admin.get(path, { params: dateRange(seed, { unit: 'day' }) });
const shared = await (await share()).get(path, { params });
const denied = await viewer.get(path, { params });
expect(response.status).toBe(200);
expect(response.body.length).toBeGreaterThan(0);
expect(response.body[0]).toEqual({
x: expect.any(String),
t: expect.any(String),
y: expect.any(Number),
});
const names = new Set(response.body.map((row: any) => row.x));
for (const name of seed.data.eventNames) {
expect(names).toContain(name);
}
expect(limited.status).toBe(200);
expect(new Set(limited.body.map((row: any) => row.x)).size).toBe(1);
expect(missingTimezone.status).toBe(400);
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
});
test('GET /api/websites/{websiteId}/events/stats returns event totals', async ({
admin,
viewer,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/events/stats`;
const response = await admin.get(path, { params: dateRange(seed) });
const missingRange = await admin.get(path);
const denied = await viewer.get(path, { params: dateRange(seed) });
expect(response.status).toBe(200);
expect(response.body.data).toMatchObject({
events: expect.anything(),
visitors: expect.anything(),
visits: expect.anything(),
uniqueEvents: expect.anything(),
comparison: expect.any(Object),
});
expect(Number(response.body.data.events)).toBeGreaterThan(0);
expect(Number(response.body.data.uniqueEvents)).toBeGreaterThanOrEqual(
seed.data.eventNames.length,
);
expect(missingRange.status).toBe(400);
expect(denied.status).toBe(401);
});
test('GET /api/realtime/{websiteId} returns realtime activity', async ({
admin,
share,
viewer,
seed,
}) => {
const path = `/api/realtime/${seed.website.id}`;
const response = await admin.get(path);
const withUnit = await admin.get(path, { params: { unit: 'minute', timezone: TIMEZONE } });
const invalidUnit = await admin.get(path, { params: { unit: 'bogus' } });
const shared = await (await share()).get(path);
const denied = await viewer.get(path);
expect(response.status).toBe(200);
// countries/urls/referrers are `{ [key]: count }` maps; events is the raw event list.
expect(response.body).toMatchObject({
countries: expect.any(Object),
urls: expect.any(Object),
referrers: expect.any(Object),
events: expect.any(Array),
series: { views: expect.any(Array), visitors: expect.any(Array) },
totals: {
views: expect.any(Number),
visitors: expect.any(Number),
events: expect.any(Number),
countries: expect.any(Number),
},
timestamp: expect.any(Number),
});
expect(Array.isArray(response.body.urls)).toBe(false);
expect(withUnit.status).toBe(200);
expect(invalidUnit.status).toBe(400);
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
});
});