470 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|