Files
umami/tests/api/event-data.spec.ts

566 lines
20 KiB
TypeScript

import { expect, test } from './fixtures';
import { dateRange, TIMEZONE } from './helpers/dates';
/**
* Seeded event data on `seed.website` (see seed/dataset.ts):
* - `purchase` x9 (3 identified personas x 3 days) with revenue 49.5 / 59.5 / 69.5,
* currency 'USD', quantity 2, items ['sku-1','sku-2'], orderedAt (date), premium (boolean).
* - `signup` x6 with plan 'free' | 'pro' and seats 1..6.
* - `download` x6 with file 'guide.pdf'.
* Other specs may ingest extra events concurrently, so list assertions use `toContain`
* while `purchase`-scoped aggregates are asserted exactly.
*/
const PURCHASE_REVENUES = [49.5, 59.5, 69.5];
const PURCHASE_TOTAL = PURCHASE_REVENUES.reduce((sum, value) => sum + value, 0) * 3;
test.describe('Event data', () => {
let base = '';
test.beforeAll(({ seed }) => {
base = `/api/websites/${seed.website.id}`;
});
test('GET /api/websites/{websiteId}/event-data lists events with their properties', async ({
admin,
share,
viewer,
seed,
}) => {
const response = await admin.get(`${base}/event-data`, {
params: dateRange(seed, { pageSize: 5 }),
});
const filtered = await admin.get(`${base}/event-data`, {
params: dateRange(seed, { event: 'purchase', pageSize: 100 }),
});
const shared = await (await share()).get(`${base}/event-data`, { params: dateRange(seed) });
const denied = await viewer.get(`${base}/event-data`, { params: dateRange(seed) });
const missingRange = await admin.get(`${base}/event-data`);
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.length).toBeLessThanOrEqual(5);
expect(response.body.data[0]).toMatchObject({
websiteId: seed.website.id,
eventId: expect.any(String),
eventName: expect.any(String),
eventProperties: expect.arrayContaining([
expect.objectContaining({ dataKey: expect.any(String), dataType: expect.any(Number) }),
]),
});
expect(filtered.status).toBe(200);
expect(filtered.body.data.length).toBeGreaterThanOrEqual(9);
for (const row of filtered.body.data) {
expect(row.eventName).toBe('purchase');
expect(row.eventProperties.map((p: any) => p.dataKey)).toEqual(
expect.arrayContaining([
'revenue',
'currency',
'quantity',
'items',
'orderedAt',
'premium',
]),
);
}
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
expect(missingRange.status).toBe(400);
});
test('GET /api/websites/{websiteId}/event-data/{eventId} returns the raw rows of one event', async ({
admin,
viewer,
seed,
}) => {
const response = await admin.get(`${base}/event-data/${seed.data.eventId}`);
const denied = await viewer.get(`${base}/event-data/${seed.data.eventId}`);
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
for (const row of response.body) {
expect(row).toMatchObject({
websiteId: seed.website.id,
eventId: seed.data.eventId,
dataKey: expect.any(String),
dataType: expect.any(Number),
createdAt: expect.any(String),
});
}
const byKey = Object.fromEntries(response.body.map((row: any) => [row.dataKey, row]));
expect(byKey.currency).toMatchObject({ stringValue: seed.data.currency, dataType: 1 });
expect(PURCHASE_REVENUES).toContain(Number(byKey.revenue.numberValue));
expect(byKey.revenue.dataType).toBe(2);
expect(byKey.premium.dataType).toBe(3);
expect(byKey.orderedAt.dataType).toBe(4);
expect(byKey.orderedAt.dateValue).toEqual(expect.any(String));
expect(byKey.items.dataType).toBe(5);
expect(denied.status).toBe(401);
});
test('GET /api/websites/{websiteId}/event-data/events breaks properties down per value', async ({
admin,
share,
viewer,
seed,
}) => {
const response = await admin.get(`${base}/event-data/events`, { params: dateRange(seed) });
// Note: this endpoint filters on `event`, while /fields uses `eventName`.
const filtered = await admin.get(`${base}/event-data/events`, {
params: dateRange(seed, { event: 'purchase' }),
});
const shared = await (await share()).get(`${base}/event-data/events`, {
params: dateRange(seed),
});
const denied = await viewer.get(`${base}/event-data/events`, { params: dateRange(seed) });
const missingRange = await admin.get(`${base}/event-data/events`);
expect(response.status).toBe(200);
expect(response.body).toEqual(
expect.arrayContaining([
expect.objectContaining({
eventName: expect.any(String),
propertyName: expect.any(String),
dataType: expect.any(Number),
total: expect.any(Number),
}),
]),
);
expect(filtered.status).toBe(200);
expect(filtered.body.every((row: any) => row.eventName === 'purchase')).toBe(true);
expect(filtered.body).toEqual(
expect.arrayContaining([
expect.objectContaining({
propertyName: 'currency',
propertyValue: seed.data.currency,
dataType: 1,
total: 9,
}),
]),
);
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
expect(missingRange.status).toBe(400);
});
test('GET /api/websites/{websiteId}/event-data/fields lists the properties of an event', async ({
admin,
share,
viewer,
seed,
}) => {
const response = await admin.get(`${base}/event-data/fields`, {
params: dateRange(seed, { eventName: 'purchase' }),
});
const all = await admin.get(`${base}/event-data/fields`, { params: dateRange(seed) });
const shared = await (await share()).get(`${base}/event-data/fields`, {
params: dateRange(seed),
});
const denied = await viewer.get(`${base}/event-data/fields`, { params: dateRange(seed) });
const missingRange = await admin.get(`${base}/event-data/fields`);
expect(response.status).toBe(200);
expect(response.body).toEqual(
expect.arrayContaining([
{ propertyName: 'currency', dataType: 1, total: 9 },
{ propertyName: 'revenue', dataType: 2, total: 9 },
{ propertyName: 'premium', dataType: 3, total: 9 },
{ propertyName: 'orderedAt', dataType: 4, total: 9 },
{ propertyName: 'items', dataType: 5, total: 9 },
{ propertyName: 'quantity', dataType: 2, total: 9 },
]),
);
expect(all.status).toBe(200);
expect(all.body.map((row: any) => row.propertyName)).toEqual(
expect.arrayContaining(['revenue', 'plan', 'seats', 'file']),
);
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
expect(missingRange.status).toBe(400);
});
test('GET /api/websites/{websiteId}/event-data/properties lists properties per event', async ({
admin,
share,
viewer,
seed,
}) => {
const response = await admin.get(`${base}/event-data/properties`, {
params: dateRange(seed),
});
const shared = await (await share()).get(`${base}/event-data/properties`, {
params: dateRange(seed),
});
const denied = await viewer.get(`${base}/event-data/properties`, { params: dateRange(seed) });
const missingRange = await admin.get(`${base}/event-data/properties`);
expect(response.status).toBe(200);
expect(response.body).toEqual(
expect.arrayContaining([
{ eventName: 'purchase', propertyName: 'revenue', dataType: 2, total: 9 },
{ eventName: 'signup', propertyName: 'plan', dataType: 1, total: 6 },
{ eventName: 'signup', propertyName: 'seats', dataType: 2, total: 6 },
{ eventName: 'download', propertyName: 'file', dataType: 1, total: 6 },
]),
);
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
expect(missingRange.status).toBe(400);
});
test('GET /api/websites/{websiteId}/event-data/stats returns totals', async ({
admin,
share,
viewer,
seed,
}) => {
const response = await admin.get(`${base}/event-data/stats`, { params: dateRange(seed) });
const shared = await (await share()).get(`${base}/event-data/stats`, {
params: dateRange(seed),
});
const denied = await viewer.get(`${base}/event-data/stats`, { params: dateRange(seed) });
const missingRange = await admin.get(`${base}/event-data/stats`);
expect(response.status).toBe(200);
expect(Object.keys(response.body).sort()).toEqual(['events', 'properties', 'records']);
// 9 purchases + 6 signups + 6 downloads (other specs may add more).
expect(Number(response.body.events)).toBeGreaterThanOrEqual(21);
// purchase: 6 props, signup: 2, download: 1.
expect(Number(response.body.properties)).toBeGreaterThanOrEqual(9);
// 9*6 + 6*2 + 6*1 = 72 property records.
expect(Number(response.body.records)).toBeGreaterThanOrEqual(72);
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
expect(missingRange.status).toBe(400);
});
test('GET /api/websites/{websiteId}/event-data/values returns value counts for a property', async ({
admin,
share,
viewer,
seed,
}) => {
const response = await admin.get(`${base}/event-data/values`, {
params: dateRange(seed, { propertyName: 'plan', eventName: 'signup' }),
});
const typed = await admin.get(`${base}/event-data/values`, {
params: dateRange(seed, { propertyName: 'revenue', eventName: 'purchase', dataType: 2 }),
});
const shared = await (await share()).get(`${base}/event-data/values`, {
params: dateRange(seed, { propertyName: 'plan' }),
});
const denied = await viewer.get(`${base}/event-data/values`, {
params: dateRange(seed, { propertyName: 'plan' }),
});
const missingProperty = await admin.get(`${base}/event-data/values`, {
params: dateRange(seed),
});
expect(response.status).toBe(200);
expect(response.body).toEqual(
expect.arrayContaining([
{ value: 'free', total: 3 },
{ value: 'pro', total: 3 },
]),
);
expect(typed.status).toBe(200);
expect(typed.body.map((row: any) => Number(row.value)).sort()).toEqual(PURCHASE_REVENUES);
expect(typed.body.every((row: any) => row.total === 3)).toBe(true);
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
expect(missingProperty.status).toBe(400);
});
test('GET /api/websites/{websiteId}/event-data-pivot pivots event properties into columns', async ({
admin,
share,
viewer,
seed,
}) => {
const response = await admin.get(`${base}/event-data-pivot`, {
params: dateRange(seed, {
eventName: 'purchase',
timezone: TIMEZONE,
unit: 'day',
pageSize: 100,
}),
});
const paged = await admin.get(`${base}/event-data-pivot`, {
params: dateRange(seed, { eventName: 'purchase', page: 2, pageSize: 4 }),
});
const propertyFiltered = await admin.get(`${base}/event-data-pivot`, {
params: dateRange(seed, { eventName: 'purchase', pf_premium: '3.eq.true', pageSize: 100 }),
});
const shared = await (await share()).get(`${base}/event-data-pivot`, {
params: dateRange(seed, { eventName: 'purchase' }),
});
const denied = await viewer.get(`${base}/event-data-pivot`, {
params: dateRange(seed, { eventName: 'purchase' }),
});
const missingEvent = await admin.get(`${base}/event-data-pivot`, {
params: dateRange(seed),
});
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ count: 9, page: 1, pageSize: 100 });
expect(response.body.data).toHaveLength(9);
for (const row of response.body.data) {
expect(row).toMatchObject({
eventId: expect.any(String),
sessionId: expect.any(String),
eventName: 'purchase',
urlPath: '/',
createdAt: expect.any(String),
propertyKeys: expect.any(Array),
propertyValues: expect.any(Array),
});
// Key order is backend-specific (Postgres sorts, ClickHouse keeps insertion order).
expect([...row.propertyKeys].sort()).toEqual([
'currency',
'items',
'orderedAt',
'premium',
'quantity',
'revenue',
]);
expect(row.propertyValues).toHaveLength(row.propertyKeys.length);
expect(row.propertyValues[row.propertyKeys.indexOf('currency')]).toBe(seed.data.currency);
}
expect(paged.status).toBe(200);
expect(paged.body).toMatchObject({ count: 9, page: 2, pageSize: 4 });
expect(paged.body.data).toHaveLength(4);
// Only alice (persona index 0) buys with premium=true: 3 purchases.
expect(propertyFiltered.status).toBe(200);
expect(propertyFiltered.body.count).toBe(3);
expect(
propertyFiltered.body.data.every(
(row: any) => row.propertyValues[row.propertyKeys.indexOf('premium')] === 'true',
),
).toBe(true);
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
expect(missingEvent.status).toBe(400);
});
test('GET /api/websites/{websiteId}/event-data-pivot/array-series expands array properties over time', async ({
admin,
share,
viewer,
seed,
}) => {
const params = dateRange(seed, { eventName: 'purchase', propertyName: 'items' });
const response = await admin.get(`${base}/event-data-pivot/array-series`, { params });
const shared = await (await share()).get(`${base}/event-data-pivot/array-series`, { params });
const denied = await viewer.get(`${base}/event-data-pivot/array-series`, { params });
const missingProperty = await admin.get(`${base}/event-data-pivot/array-series`, {
params: dateRange(seed, { eventName: 'purchase' }),
});
expect(response.status).toBe(200);
expect(response.body.length).toBeGreaterThan(0);
for (const point of response.body) {
expect(point).toEqual({
x: expect.any(String),
t: expect.any(String),
y: expect.any(Number),
});
}
expect(new Set(response.body.map((point: any) => point.x))).toEqual(
new Set(['sku-1', 'sku-2']),
);
// Every purchase carries both skus: 9 occurrences each.
for (const sku of ['sku-1', 'sku-2']) {
const total = response.body
.filter((point: any) => point.x === sku)
.reduce((sum: number, point: any) => sum + point.y, 0);
expect(total).toBe(9);
}
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
expect(missingProperty.status).toBe(400);
});
test('GET /api/websites/{websiteId}/event-data-pivot/date-series counts date properties', async ({
admin,
share,
viewer,
seed,
}) => {
const params = dateRange(seed, { eventName: 'purchase', propertyName: 'orderedAt' });
const response = await admin.get(`${base}/event-data-pivot/date-series`, { params });
const shared = await (await share()).get(`${base}/event-data-pivot/date-series`, { params });
const denied = await viewer.get(`${base}/event-data-pivot/date-series`, { params });
const missingEvent = await admin.get(`${base}/event-data-pivot/date-series`, {
params: dateRange(seed, { propertyName: 'orderedAt' }),
});
expect(response.status).toBe(200);
expect(response.body).toHaveLength(9);
for (const point of response.body) {
expect(point).toEqual({ t: expect.any(String), y: 1 });
expect(Number.isNaN(new Date(point.t).getTime())).toBe(false);
}
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
expect(missingEvent.status).toBe(400);
});
test('GET /api/websites/{websiteId}/event-data-pivot/numeric-series aggregates numeric properties', async ({
admin,
share,
viewer,
seed,
}) => {
const params = dateRange(seed, {
eventName: 'purchase',
propertyName: 'revenue',
unit: 'day',
timezone: TIMEZONE,
});
const sum = await admin.get(`${base}/event-data-pivot/numeric-series`, { params });
const avg = await admin.get(`${base}/event-data-pivot/numeric-series`, {
params: { ...params, metric: 'avg' },
});
const count = await admin.get(`${base}/event-data-pivot/numeric-series`, {
params: { ...params, metric: 'count' },
});
const shared = await (await share()).get(`${base}/event-data-pivot/numeric-series`, {
params,
});
const denied = await viewer.get(`${base}/event-data-pivot/numeric-series`, { params });
const badMetric = await admin.get(`${base}/event-data-pivot/numeric-series`, {
params: { ...params, metric: 'max' },
});
const missingProperty = await admin.get(`${base}/event-data-pivot/numeric-series`, {
params: dateRange(seed, { eventName: 'purchase' }),
});
// Purchases happen on 3 distinct days, 3 per day.
expect(sum.status).toBe(200);
expect(sum.body).toHaveLength(3);
for (const point of sum.body) {
expect(point).toEqual({ t: expect.any(String), y: expect.anything() });
expect(Number(point.y)).toBeCloseTo(PURCHASE_TOTAL / 3, 5);
}
expect(avg.status).toBe(200);
expect(avg.body).toHaveLength(3);
for (const point of avg.body) {
expect(Number(point.y)).toBeCloseTo(59.5, 5);
}
expect(count.status).toBe(200);
expect(count.body).toHaveLength(3);
for (const point of count.body) {
expect(Number(point.y)).toBe(3);
}
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
expect(badMetric.status).toBe(400);
expect(missingProperty.status).toBe(400);
});
test('GET /api/websites/{websiteId}/event-data-pivot/numeric-stats summarises a numeric property', async ({
admin,
share,
viewer,
seed,
}) => {
const params = dateRange(seed, { eventName: 'purchase', propertyName: 'revenue' });
const response = await admin.get(`${base}/event-data-pivot/numeric-stats`, { params });
const shared = await (await share()).get(`${base}/event-data-pivot/numeric-stats`, { params });
const denied = await viewer.get(`${base}/event-data-pivot/numeric-stats`, { params });
const missingProperty = await admin.get(`${base}/event-data-pivot/numeric-stats`, {
params: dateRange(seed, { eventName: 'purchase' }),
});
expect(response.status).toBe(200);
expect(Object.keys(response.body).sort()).toEqual(['average', 'max', 'median', 'min', 'total']);
expect(Number(response.body.min)).toBeCloseTo(49.5, 5);
expect(Number(response.body.max)).toBeCloseTo(69.5, 5);
expect(Number(response.body.average)).toBeCloseTo(59.5, 5);
expect(Number(response.body.median)).toBeCloseTo(59.5, 5);
expect(Number(response.body.total)).toBeCloseTo(PURCHASE_TOTAL, 5);
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
expect(missingProperty.status).toBe(400);
});
test('GET /api/websites/{websiteId}/event-data-pivot/property-series counts property values over time', async ({
admin,
share,
viewer,
seed,
}) => {
const premium = await admin.get(`${base}/event-data-pivot/property-series`, {
params: dateRange(seed, {
eventName: 'purchase',
propertyName: 'premium',
unit: 'day',
timezone: TIMEZONE,
}),
});
const plan = await admin.get(`${base}/event-data-pivot/property-series`, {
params: dateRange(seed, { eventName: 'signup', propertyName: 'plan', unit: 'day' }),
});
const shared = await (await share()).get(`${base}/event-data-pivot/property-series`, {
params: dateRange(seed, { eventName: 'signup', propertyName: 'plan' }),
});
const denied = await viewer.get(`${base}/event-data-pivot/property-series`, {
params: dateRange(seed, { eventName: 'signup', propertyName: 'plan' }),
});
const missingProperty = await admin.get(`${base}/event-data-pivot/property-series`, {
params: dateRange(seed, { eventName: 'signup' }),
});
expect(premium.status).toBe(200);
// 3 days x (2 non-premium + 1 premium).
expect(premium.body).toHaveLength(6);
for (const point of premium.body) {
expect(point).toEqual({
x: expect.any(String),
t: expect.any(String),
y: expect.any(Number),
});
}
const totals = (rows: any[]) =>
rows.reduce<Record<string, number>>((acc, row) => {
acc[row.x] = (acc[row.x] ?? 0) + row.y;
return acc;
}, {});
expect(totals(premium.body)).toEqual({ true: 3, false: 6 });
expect(plan.status).toBe(200);
expect(totals(plan.body)).toEqual({ pro: 3, free: 3 });
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
expect(missingProperty.status).toBe(400);
});
});