Files
umami/tests/api/sessions.spec.ts
T

335 lines
12 KiB
TypeScript

import { expect, test } from './fixtures';
import { EVENT_TYPE, UNKNOWN_UUID } from './helpers/constants';
import { dateRange, TIMEZONE } from './helpers/dates';
import { createWebsite, deleteWebsite } from './helpers/entities';
import { PERSONAS } from './seed/dataset';
const sessionShape = {
id: expect.any(String),
websiteId: expect.any(String),
firstAt: expect.any(String),
lastAt: expect.any(String),
createdAt: expect.any(String),
visits: expect.anything(),
views: expect.anything(),
events: expect.anything(),
};
test.describe('Sessions', () => {
test.describe.configure({ mode: 'serial' });
/** A seeded session that carries a distinct id (identified persona). */
let identifiedSessionId = '';
test('GET /api/websites/{websiteId}/sessions lists sessions with paging', async ({
admin,
share,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/sessions`;
const response = await admin.get(path, { params: dateRange(seed, { pageSize: 5 }) });
const second = await admin.get(path, { params: dateRange(seed, { pageSize: 5, page: 2 }) });
const shared = await (await share()).get(path, { params: dateRange(seed) });
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).toBeGreaterThan(5);
expect(response.body.data[0]).toMatchObject({ ...sessionShape, websiteId: seed.website.id });
// `distinctId` is omitted from rows for anonymous sessions.
for (const key of ['browser', 'os', 'device', 'screen', 'language', 'country']) {
expect(response.body.data[0]).toHaveProperty(key);
}
expect(response.body.data.map((s: any) => s.id)).not.toContain(second.body.data[0].id);
expect(shared.status).toBe(200);
});
test('GET /api/websites/{websiteId}/sessions filters by distinctId and search', async ({
admin,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/sessions`;
const [distinctId] = seed.data.distinctIds;
const unfiltered = await admin.get(path, { params: dateRange(seed) });
const filtered = await admin.get(path, { params: dateRange(seed, { distinctId }) });
const searched = await admin.get(path, { params: dateRange(seed, { search: distinctId }) });
const none = await admin.get(path, {
params: dateRange(seed, { distinctId: 'zzz-no-such-user' }),
});
expect(filtered.status).toBe(200);
expect(filtered.body.data.length).toBeGreaterThan(0);
expect(filtered.body.count).toBeLessThan(unfiltered.body.count);
expect(filtered.body.data[0]).toMatchObject({ ...sessionShape, websiteId: seed.website.id });
expect(searched.status).toBe(200);
expect(searched.body.data.length).toBeGreaterThan(0);
expect(none.body.data).toHaveLength(0);
expect(none.body.count).toBe(0);
// Only the session that received the `identify` call carries session data.
for (const session of filtered.body.data) {
const properties = await admin.get(`${path}/${session.id}/properties`);
if (properties.body?.length) {
identifiedSessionId = session.id;
break;
}
}
expect(identifiedSessionId).not.toBe('');
});
test('GET /api/websites/{websiteId}/sessions validates params and permissions', async ({
admin,
api,
viewer,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/sessions`;
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}/sessions`, {
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}/sessions/{sessionId} returns a session', async ({
admin,
share,
viewer,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/sessions/${seed.data.sessionId}`;
const response = await admin.get(path);
const identified = await admin.get(
`/api/websites/${seed.website.id}/sessions/${identifiedSessionId}`,
);
const shared = await (await share()).get(path);
const denied = await viewer.get(path);
const unknown = await admin.get(`/api/websites/${seed.website.id}/sessions/${UNKNOWN_UUID}`);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
id: seed.data.sessionId,
websiteId: seed.website.id,
canDelete: seed.db === 'postgres',
stitchedSessionCount: expect.any(Number),
});
expect(response.body.stitchedSessionCount).toBeGreaterThanOrEqual(1);
expect(identified.status).toBe(200);
expect(identified.body.distinctId).toBe(seed.data.distinctIds[0]);
expect(identified.body.stitchedSessionCount).toBeGreaterThanOrEqual(1);
expect(shared.status).toBe(200);
expect(shared.body.id).toBe(seed.data.sessionId);
expect(shared.body.canDelete).toBe(false);
expect(denied.status).toBe(401);
expect(unknown.status).toBe(404);
});
test('GET /api/websites/{websiteId}/sessions/{sessionId}/activity returns the event log', async ({
admin,
viewer,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/sessions/${seed.data.sessionId}/activity`;
const response = await admin.get(path, { params: dateRange(seed) });
const stitched = await admin.get(
`/api/websites/${seed.website.id}/sessions/${identifiedSessionId}/activity`,
{ params: dateRange(seed, { distinctId: seed.data.distinctIds[0] }) },
);
const missingRange = await admin.get(path);
const invalid = await admin.get(path, { params: { startAt: 'abc', endAt: 'def' } });
const denied = await viewer.get(path, { params: dateRange(seed) });
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
expect(response.body[0]).toMatchObject({
eventId: expect.any(String),
visitId: expect.any(String),
createdAt: expect.any(String),
urlPath: expect.any(String),
eventType: expect.any(Number),
});
expect(response.body[0]).toHaveProperty('eventName');
expect(response.body[0]).toHaveProperty('hasData');
expect(response.body.some((row: any) => row.eventType === EVENT_TYPE.pageView)).toBe(true);
expect(stitched.status).toBe(200);
expect(stitched.body.length).toBeGreaterThan(0);
expect(missingRange.status).toBe(400);
expect(invalid.status).toBe(400);
expect(denied.status).toBe(401);
});
test('GET /api/websites/{websiteId}/sessions/{sessionId}/properties returns session data', async ({
admin,
viewer,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/sessions/${identifiedSessionId}/properties`;
const response = await admin.get(path);
const unknown = await admin.get(
`/api/websites/${seed.website.id}/sessions/${UNKNOWN_UUID}/properties`,
);
const denied = await viewer.get(path);
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
expect(response.body[0]).toMatchObject({
sessionId: identifiedSessionId,
dataKey: expect.any(String),
dataType: expect.any(Number),
});
for (const key of ['stringValue', 'numberValue', 'dateValue']) {
expect(response.body[0]).toHaveProperty(key);
}
expect(unknown.status).toBe(200);
expect(unknown.body).toEqual([]);
expect(denied.status).toBe(401);
});
test('GET /api/websites/{websiteId}/sessions/stats returns session totals', async ({
admin,
share,
viewer,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/sessions/stats`;
const response = await admin.get(path, { params: dateRange(seed) });
const missingRange = await admin.get(path);
const shared = await (await share()).get(path, { params: dateRange(seed) });
const denied = await viewer.get(path, { params: dateRange(seed) });
expect(response.status).toBe(200);
expect(response.body).toEqual({
pageviews: { value: expect.anything() },
visitors: { value: expect.anything() },
visits: { value: expect.anything() },
countries: { value: expect.anything() },
events: { value: expect.anything() },
});
expect(Number(response.body.pageviews.value)).toBeGreaterThanOrEqual(
seed.data.expectedPageviews,
);
expect(Number(response.body.visitors.value)).toBeGreaterThan(0);
expect(Number(response.body.events.value)).toBeGreaterThan(0);
expect(missingRange.status).toBe(400);
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
});
test('GET /api/websites/{websiteId}/sessions/weekly returns a 7x24 grid', async ({
admin,
share,
viewer,
seed,
}) => {
const path = `/api/websites/${seed.website.id}/sessions/weekly`;
const params = dateRange(seed, { timezone: TIMEZONE });
const response = await admin.get(path, { params });
const missingTimezone = await admin.get(path, { params: dateRange(seed) });
const shared = await (await share()).get(path, { params });
const denied = await viewer.get(path, { params });
expect(response.status).toBe(200);
expect(response.body).toHaveLength(7);
for (const day of response.body) {
expect(day).toHaveLength(24);
for (const hour of day) {
expect(typeof hour).toBe('number');
}
}
const total = response.body.flat().reduce((sum: number, n: number) => sum + n, 0);
expect(total).toBeGreaterThan(0);
expect(missingTimezone.status).toBe(400);
expect(shared.status).toBe(200);
expect(denied.status).toBe(401);
});
test.describe('DELETE /api/websites/{websiteId}/sessions/{sessionId}', () => {
let websiteId = '';
let sessionId = '';
test.beforeAll(async ({ admin, api, seed }) => {
test.skip(seed.db === 'clickhouse', 'Session deletion requires relational storage');
websiteId = (await createWebsite(admin)).id;
const sent = await api.post('/api/send', {
type: 'event',
payload: {
website: websiteId,
hostname: 'x.test',
url: '/',
ip: '10.0.9.9',
userAgent: PERSONAS[0].userAgent,
},
});
expect(sent.status).toBe(200);
await expect
.poll(
async () => {
const sessions = await admin.get(`/api/websites/${websiteId}/sessions`, {
params: { startAt: Date.now() - 60 * 60 * 1000, endAt: Date.now() },
});
sessionId = sessions.body?.data?.[0]?.id ?? '';
return sessionId;
},
{ timeout: 15_000 },
)
.not.toBe('');
});
test.afterAll(async ({ admin }) => {
if (websiteId) {
await deleteWebsite(admin, websiteId);
}
});
test('DELETE /api/websites/{websiteId}/sessions/{sessionId} deletes a session', async ({
admin,
api,
user,
seed,
}) => {
const path = `/api/websites/${websiteId}/sessions/${sessionId}`;
const anonymous = await api.del(path);
const denied = await user.del(path);
const before = await admin.get(path);
const response = await admin.del(path);
const after = await admin.get(path);
const unknown = await admin.del(`/api/websites/${websiteId}/sessions/${UNKNOWN_UUID}`);
const list = await admin.get(`/api/websites/${websiteId}/sessions`, {
params: dateRange(seed),
});
expect(anonymous.status).toBe(401);
expect(denied.status).toBe(401);
expect(before.status).toBe(200);
expect(before.body.canDelete).toBe(true);
expect(response.status).toBe(200);
expect(response.body).toEqual({ ok: true });
expect(after.status).toBe(404);
expect(unknown.status).toBe(404);
expect(list.body.data).toHaveLength(0);
});
});
});