diff --git a/.dockerignore b/.dockerignore
index c1a7e94e6..fda7aa837 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -8,3 +8,9 @@ node_modules
.env.*
scripts/seed
scripts/seed-data.ts
+tests
+playwright.config.ts
+playwright.api.config.ts
+docker-compose.test.yml
+playwright-report
+test-results
diff --git a/.gitignore b/.gitignore
index 5085c3251..15ff7b885 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,3 +53,6 @@ yarn-error.log*
*.env.*
*.dev.yml
+
+# api integration test runtime state
+/tests/api/.runtime
diff --git a/Dockerfile b/Dockerfile
index 10034b9a8..49b628c18 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -22,7 +22,10 @@ RUN pnpm install --frozen-lockfile
# Rebuild the source code only when needed
FROM node:${NODE_IMAGE_VERSION} AS builder
+ARG PNPM_VERSION
WORKDIR /app
+# build-openapi shells out to pnpm, so the builder stage needs it too
+RUN npm install -g pnpm@${PNPM_VERSION}
COPY --from=deps /app/node_modules ./node_modules
COPY . .
COPY docker/proxy.ts ./src
@@ -33,6 +36,9 @@ ENV BASE_PATH=$BASE_PATH
ENV NEXT_TELEMETRY_DISABLED=1
ENV DATABASE_URL="postgresql://user:pass@localhost:5432/dummy"
+ARG SKIP_BUILD_GEO
+ENV SKIP_BUILD_GEO=$SKIP_BUILD_GEO
+
RUN npm run build-docker
# Production image, copy all the files and run next
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
new file mode 100644
index 000000000..8c68caced
--- /dev/null
+++ b/docker-compose.test.yml
@@ -0,0 +1,107 @@
+# Compose stack for the API integration test suite (`pnpm test:api`).
+#
+# This is NOT the deployable stack — see docker-compose.yml for that. Databases
+# run on tmpfs and are discarded on `down`, so every run starts from an empty
+# schema (migrations + the default admin user are applied by the umami image on
+# boot via scripts/start-docker.sh).
+#
+# Profiles:
+# postgres umami + Postgres -> pnpm test:api
+# clickhouse umami + Postgres + ClickHouse -> pnpm test:api:clickhouse
+#
+# Environment overrides:
+# UMAMI_TEST_PORT host port for the app (default 3100, avoids the dev server on 3000)
+# UMAMI_TEST_IMAGE image tag to run/build (default umami-test:local)
+# SKIP_BUILD_GEO "1" (default) skips the MaxMind download during the image build
+
+x-umami-service: &umami-service
+ image: ${UMAMI_TEST_IMAGE:-umami-test:local}
+ build:
+ context: .
+ args:
+ SKIP_BUILD_GEO: ${SKIP_BUILD_GEO:-1}
+ ports:
+ - "${UMAMI_TEST_PORT:-3100}:3000"
+ init: true
+ healthcheck:
+ test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/heartbeat || exit 1"]
+ interval: 2s
+ timeout: 5s
+ retries: 90
+ start_period: 20s
+
+x-umami-env: &umami-env
+ DATABASE_URL: postgresql://umami:umami@db:5432/umami
+ APP_SECRET: umami-api-test-secret
+ TWO_FACTOR_ENCRYPTION_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
+ # Playwright/curl user agents are classified as bots; without this /api/send silently drops events.
+ DISABLE_BOT_CHECK: "1"
+ DISABLE_TELEMETRY: "1"
+ DISABLE_UPDATES: "1"
+
+services:
+ db:
+ image: postgres:15-alpine
+ environment:
+ POSTGRES_DB: umami
+ POSTGRES_USER: umami
+ POSTGRES_PASSWORD: umami
+ tmpfs:
+ - /var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U umami -d umami"]
+ interval: 2s
+ timeout: 5s
+ retries: 30
+
+ umami:
+ <<: *umami-service
+ profiles: [postgres]
+ environment: *umami-env
+ depends_on:
+ db:
+ condition: service_healthy
+
+ clickhouse:
+ # Pinned to an LTS release. 26.7+ rejects db/clickhouse/schema.sql because
+ # website_event_stats_hourly has dimension columns outside the sorting key
+ # (see allow_dimensions_outside_sorting_key).
+ image: clickhouse/clickhouse-server:25.8
+ profiles: [clickhouse]
+ environment:
+ CLICKHOUSE_DB: umami
+ CLICKHOUSE_USER: umami
+ CLICKHOUSE_PASSWORD: umami
+ ulimits:
+ nofile:
+ soft: 262144
+ hard: 262144
+ tmpfs:
+ - /var/lib/clickhouse
+ volumes:
+ # The image runs /docker-entrypoint-initdb.d/*.sql on first start (empty data dir).
+ - ./tests/api/docker/clickhouse-init/00-create-db.sql:/docker-entrypoint-initdb.d/00-create-db.sql:ro
+ - ./db/clickhouse/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
+ healthcheck:
+ # Healthy only once schema.sql has been applied (session_link is the last table it creates).
+ test:
+ [
+ "CMD-SHELL",
+ "clickhouse-client --user umami --password umami --query \"SELECT throwIf(count() = 0) FROM system.tables WHERE database = 'umami' AND name = 'session_link'\"",
+ ]
+ interval: 2s
+ timeout: 5s
+ retries: 90
+ start_period: 10s
+
+ umami-clickhouse:
+ <<: *umami-service
+ profiles: [clickhouse]
+ environment:
+ <<: *umami-env
+ CLICKHOUSE_URL: http://umami:umami@clickhouse:8123/umami
+ depends_on:
+ db:
+ condition: service_healthy
+ clickhouse:
+ condition: service_healthy
diff --git a/package.json b/package.json
index a8c44296c..357656312 100644
--- a/package.json
+++ b/package.json
@@ -56,6 +56,12 @@
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
+ "test:api": "tsx scripts/test-api.ts",
+ "test:api:clickhouse": "tsx scripts/test-api.ts --clickhouse",
+ "test:api:run": "playwright test -c playwright.api.config.ts",
+ "test:api:up": "tsx scripts/test-api.ts --up-only",
+ "test:api:down": "tsx scripts/test-api.ts --down",
+ "test:api:coverage": "tsx scripts/test-api.ts --coverage-report",
"seed-data": "tsx scripts/seed-data.ts",
"lint": "biome lint .",
"format": "biome format --write .",
diff --git a/playwright.api.config.ts b/playwright.api.config.ts
new file mode 100644
index 000000000..3e235de5c
--- /dev/null
+++ b/playwright.api.config.ts
@@ -0,0 +1,38 @@
+///
+import { defineConfig, type ReporterDescription } from '@playwright/test';
+import { BASE_URL as baseURL } from './tests/api/paths';
+
+/**
+ * Playwright config for the API integration suite (tests/api).
+ *
+ * The suite only uses the `request` fixture — no browser is launched. It expects
+ * a running umami server (normally started by scripts/test-api.ts via
+ * docker-compose.test.yml) at PLAYWRIGHT_BASE_URL or http://localhost:3100.
+ */
+
+const reporters: ReporterDescription[] = [['list'], ['./tests/api/coverage/reporter.ts']];
+
+if (process.env.CI) {
+ reporters.push(['html', { open: 'never', outputFolder: 'playwright-report/api' }]);
+}
+
+export default defineConfig({
+ testDir: './tests/api',
+ testMatch: '**/*.spec.ts',
+ outputDir: 'test-results/api',
+ // Specs share one seeded database and a few mutable globals (the shared users,
+ // the global 2FA requirement toggled by admin.spec.ts), so they must run serially.
+ fullyParallel: false,
+ workers: 1,
+ // Specs are deterministic and share coverage state; retries would only hide flakes.
+ retries: 0,
+ forbidOnly: !!process.env.CI,
+ timeout: 30_000,
+ globalSetup: './tests/api/global-setup.ts',
+ reporter: reporters,
+ use: {
+ baseURL,
+ trace: 'off',
+ },
+ projects: [{ name: 'api' }],
+});
diff --git a/scripts/test-api.ts b/scripts/test-api.ts
new file mode 100644
index 000000000..690258159
--- /dev/null
+++ b/scripts/test-api.ts
@@ -0,0 +1,325 @@
+/* eslint-disable no-console */
+/**
+ * Orchestrates the API integration suite:
+ *
+ * 1. docker compose up (builds the umami image, starts Postgres [+ ClickHouse])
+ * 2. waits for /api/heartbeat
+ * 3. runs Playwright against the container (tests/api, playwright.api.config.ts)
+ * 4. dumps container logs on failure
+ * 5. tears the stack down (unless --keep)
+ *
+ * Usage: tsx scripts/test-api.ts [options] [-- playwright args]
+ */
+import { spawnSync } from 'node:child_process';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { assertDisposableTarget } from '../tests/api/paths';
+
+const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const COMPOSE_FILE = 'docker-compose.test.yml';
+const PROJECT_NAME = process.env.COMPOSE_PROJECT_NAME || 'umami-api-test';
+const PORT = process.env.UMAMI_TEST_PORT || '3100';
+const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${PORT}`;
+// PLAYWRIGHT_BASE_URL selects an already-running server: no Compose stack is
+// built, started, or torn down for it.
+const EXTERNAL_TARGET = !!process.env.PLAYWRIGHT_BASE_URL;
+const HEARTBEAT_TIMEOUT_MS = 180_000;
+// Mirrors RUNTIME_DIR in tests/api/paths.ts (one state dir per target host).
+const LOG_FILE = path.join(
+ ROOT_DIR,
+ 'tests/api/.runtime',
+ new URL(BASE_URL).host.replace(/[^a-z0-9.-]+/gi, '-'),
+ 'docker.log',
+);
+
+const HELP = `
+Usage: pnpm test:api [options] [-- playwright args]
+
+Options:
+ --clickhouse Run against Postgres + ClickHouse (analytics stored in ClickHouse)
+ --keep Leave the containers running after the tests finish
+ --no-build Skip the image build (use the existing UMAMI_TEST_IMAGE)
+ --up-only Start the stack, wait for readiness, and exit (see --keep)
+ --down Tear down the stack and exit
+ --grep Only run matching tests (implies --coverage-report)
+ --coverage-report Print endpoint coverage without failing the run
+ -h, --help Show this help
+
+Environment:
+ UMAMI_TEST_PORT Host port for the app (default 3100)
+ UMAMI_TEST_IMAGE Image tag to build/run (default umami-test:local)
+ COMPOSE_PROJECT_NAME Compose project name (default umami-api-test)
+ PLAYWRIGHT_BASE_URL Test an already-running server instead of starting the
+ Compose stack (pass --clickhouse if it stores analytics there)
+ API_ALLOW_DESTRUCTIVE=1 Required for non-loopback PLAYWRIGHT_BASE_URL targets: the suite
+ recreates websites, ingests data and toggles global 2FA
+ API_SKIP_SEED=1 Reuse the previous seed when re-running against a kept stack
+ (each run seeds additively otherwise)
+`;
+
+interface Options {
+ clickhouse: boolean;
+ keep: boolean;
+ build: boolean;
+ upOnly: boolean;
+ down: boolean;
+ grep?: string;
+ coverageReport: boolean;
+ passthrough: string[];
+}
+
+function parseArgs(argv: string[]): Options {
+ const options: Options = {
+ clickhouse: false,
+ keep: false,
+ build: true,
+ upOnly: false,
+ down: false,
+ coverageReport: false,
+ passthrough: [],
+ };
+
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+
+ if (arg === '--') {
+ options.passthrough.push(...argv.slice(i + 1));
+ break;
+ }
+
+ if (arg.startsWith('--grep=')) {
+ options.grep = arg.slice('--grep='.length);
+ continue;
+ }
+
+ switch (arg) {
+ case '--clickhouse':
+ options.clickhouse = true;
+ break;
+ case '--keep':
+ options.keep = true;
+ break;
+ case '--no-build':
+ options.build = false;
+ break;
+ case '--up-only':
+ options.upOnly = true;
+ break;
+ case '--down':
+ options.down = true;
+ break;
+ case '--coverage-report':
+ options.coverageReport = true;
+ break;
+ case '--grep':
+ options.grep = argv[++i];
+ break;
+ case '-h':
+ case '--help':
+ console.log(HELP);
+ process.exit(0);
+ break;
+ default:
+ console.error(`Unknown option: ${arg}`);
+ console.log(HELP);
+ process.exit(2);
+ }
+ }
+
+ return options;
+}
+
+function run(command: string, args: string[], env: Record = {}) {
+ const result = spawnSync(command, args, {
+ cwd: ROOT_DIR,
+ stdio: 'inherit',
+ env: { ...process.env, ...env },
+ });
+
+ if (result.error) {
+ throw result.error;
+ }
+
+ return result.status ?? 1;
+}
+
+function capture(command: string, args: string[]) {
+ const result = spawnSync(command, args, {
+ cwd: ROOT_DIR,
+ encoding: 'utf8',
+ env: process.env,
+ maxBuffer: 64 * 1024 * 1024,
+ });
+
+ return `${result.stdout ?? ''}${result.stderr ?? ''}`;
+}
+
+function composeArgs(profiles: string[], args: string[]) {
+ return [
+ 'compose',
+ '-f',
+ COMPOSE_FILE,
+ '-p',
+ PROJECT_NAME,
+ ...profiles.flatMap(profile => ['--profile', profile]),
+ ...args,
+ ];
+}
+
+async function waitForHeartbeat(url: string, timeoutMs: number) {
+ const deadline = Date.now() + timeoutMs;
+ let lastError = '';
+
+ while (Date.now() < deadline) {
+ try {
+ const response = await fetch(`${url}/api/heartbeat`);
+
+ if (response.ok) {
+ return;
+ }
+
+ lastError = `HTTP ${response.status}`;
+ } catch (error) {
+ lastError = (error as Error).message;
+ }
+
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ }
+
+ throw new Error(`Timed out waiting for ${url}/api/heartbeat (${lastError})`);
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2));
+ const profile = options.clickhouse ? 'clickhouse' : 'postgres';
+ const appService = options.clickhouse ? 'umami-clickhouse' : 'umami';
+ const logServices = options.clickhouse ? [appService, 'db', 'clickhouse'] : [appService, 'db'];
+
+ const teardown = () => {
+ console.log('\nStopping test stack...');
+ run('docker', composeArgs(['postgres', 'clickhouse'], ['down', '-v', '--remove-orphans']));
+ };
+
+ if (EXTERNAL_TARGET && (options.upOnly || options.down)) {
+ console.error(
+ 'PLAYWRIGHT_BASE_URL selects an already-running server; --up-only and --down manage ' +
+ 'the local Compose stack and cannot be combined with it.',
+ );
+ return 2;
+ }
+
+ if (options.down) {
+ teardown();
+ return 0;
+ }
+
+ const dumpLogs = () => {
+ const logs = capture('docker', composeArgs([profile], ['logs', '--no-color', ...logServices]));
+
+ mkdirSync(path.dirname(LOG_FILE), { recursive: true });
+ writeFileSync(LOG_FILE, logs);
+
+ console.log(`\n--- docker compose logs (${logServices.join(', ')}) ---\n`);
+ console.log(logs);
+ console.log(`--- end of logs (saved to ${path.relative(ROOT_DIR, LOG_FILE)}) ---\n`);
+ };
+
+ let exitCode = 1;
+ let started = false;
+
+ const onSignal = () => {
+ console.log('\nInterrupted.');
+
+ if (started && !options.keep) {
+ teardown();
+ }
+
+ process.exit(130);
+ };
+
+ process.on('SIGINT', onSignal);
+ process.on('SIGTERM', onSignal);
+
+ try {
+ if (EXTERNAL_TARGET) {
+ try {
+ assertDisposableTarget(BASE_URL);
+ } catch (error) {
+ console.error((error as Error).message);
+ return 2;
+ }
+ console.log(`Using the already-running server at ${BASE_URL} (PLAYWRIGHT_BASE_URL is set).`);
+ } else {
+ console.log(`Starting test stack (profile: ${profile}, port: ${PORT})...`);
+
+ const upArgs = ['up', '-d', '--wait', '--wait-timeout', '300'];
+
+ if (options.build) {
+ upArgs.push('--build');
+ }
+
+ started = true;
+
+ if (run('docker', composeArgs([profile], upArgs)) !== 0) {
+ console.error('docker compose up failed.');
+ dumpLogs();
+ return 1;
+ }
+ }
+
+ await waitForHeartbeat(BASE_URL, HEARTBEAT_TIMEOUT_MS);
+
+ console.log(`umami is ready at ${BASE_URL}`);
+
+ if (options.upOnly) {
+ options.keep = true;
+ console.log(
+ `\nRun tests with: PLAYWRIGHT_BASE_URL=${BASE_URL} UMAMI_TEST_DB=${profile} pnpm test:api:run` +
+ '\nRe-running against the same stack? Add API_SKIP_SEED=1 (seeding is additive).' +
+ '\nStop the stack with: pnpm test:api:down',
+ );
+ return 0;
+ }
+
+ const playwrightArgs = ['exec', 'playwright', 'test', '-c', 'playwright.api.config.ts'];
+
+ if (options.grep) {
+ playwrightArgs.push('--grep', options.grep);
+ }
+
+ playwrightArgs.push(...options.passthrough);
+
+ exitCode = run(process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', playwrightArgs, {
+ PLAYWRIGHT_BASE_URL: BASE_URL,
+ PLAYWRIGHT_SKIP_WEB_SERVER: '1',
+ UMAMI_TEST_DB: profile,
+ API_COVERAGE:
+ process.env.API_COVERAGE ?? (options.grep || options.coverageReport ? 'report' : 'enforce'),
+ });
+
+ if (exitCode !== 0 && started) {
+ dumpLogs();
+ }
+
+ return exitCode;
+ } finally {
+ process.off('SIGINT', onSignal);
+ process.off('SIGTERM', onSignal);
+
+ if (started && !options.keep) {
+ teardown();
+ } else if (started) {
+ console.log(`\nStack left running (${BASE_URL}). Stop it with: pnpm test:api:down`);
+ }
+ }
+}
+
+main()
+ .then(code => {
+ process.exit(code);
+ })
+ .catch(error => {
+ console.error(error);
+ process.exit(1);
+ });
diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts
index 7d3331dd7..d5fdbc10f 100644
--- a/src/lib/clickhouse.ts
+++ b/src/lib/clickhouse.ts
@@ -62,17 +62,36 @@ function getUTCString(date?: Date | string | number) {
return formatInTimeZone(date || new Date(), 'UTC', 'yyyy-MM-dd HH:mm:ss');
}
+// ClickHouse time zone names are case-sensitive: several callers default to the
+// lowercase "utc" that Postgres accepts but ClickHouse rejects. Intl accepts any
+// casing and returns the canonical IANA name.
+function normalizeTimezone(timezone?: string) {
+ if (!timezone) {
+ return timezone;
+ }
+
+ try {
+ return Intl.DateTimeFormat(undefined, { timeZone: timezone }).resolvedOptions().timeZone;
+ } catch {
+ return timezone;
+ }
+}
+
function getDateStringSQL(data: any, unit: string = 'utc', timezone?: string) {
- if (timezone) {
- return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}', '${timezone}')`;
+ const tz = normalizeTimezone(timezone);
+
+ if (tz) {
+ return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}', '${tz}')`;
}
return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`;
}
function getDateSQL(field: string, unit: string, timezone?: string) {
- if (timezone) {
- return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'), '${timezone}')`;
+ const tz = normalizeTimezone(timezone);
+
+ if (tz) {
+ return `toDateTime(date_trunc('${unit}', ${field}, '${tz}'), '${tz}')`;
}
return `toDateTime(date_trunc('${unit}', ${field}))`;
}
@@ -236,21 +255,27 @@ function getQueryParams(filters: Record) {
};
}
-function parseFilters(filters: Record, options?: QueryOptions) {
+function parseFilters(rawFilters: Record, options?: QueryOptions) {
+ const filters = rawFilters.timezone
+ ? { ...rawFilters, timezone: normalizeTimezone(rawFilters.timezone) }
+ : rawFilters;
const cohortFilters = Object.fromEntries(
Object.entries(filters).filter(([key]) => key.startsWith('cohort_')),
);
- const {
- sql: eventPropertyFilterQuery,
- params: eventPropertyFilterParams,
- } = getEventPropertyFilterQuery((filters as QueryFilters).eventPropertyFilters, filters.timezone);
- const {
- sql: sessionPropertyFilterQuery,
- params: sessionPropertyFilterParams,
- } = getSessionPropertyFilterQuery((filters as QueryFilters).sessionPropertyFilters, filters.timezone);
+ const { sql: eventPropertyFilterQuery, params: eventPropertyFilterParams } =
+ getEventPropertyFilterQuery((filters as QueryFilters).eventPropertyFilters, filters.timezone);
+ const { sql: sessionPropertyFilterQuery, params: sessionPropertyFilterParams } =
+ getSessionPropertyFilterQuery(
+ (filters as QueryFilters).sessionPropertyFilters,
+ filters.timezone,
+ );
return {
- filterQuery: [getFilterQuery(filters, options), eventPropertyFilterQuery, sessionPropertyFilterQuery]
+ filterQuery: [
+ getFilterQuery(filters, options),
+ eventPropertyFilterQuery,
+ sessionPropertyFilterQuery,
+ ]
.filter(Boolean)
.join('\n'),
dateQuery: getDateQuery(filters),
diff --git a/src/queries/sql/reports/getRevenueChart.ts b/src/queries/sql/reports/getRevenueChart.ts
index a2d8e7c27..8778b27d4 100644
--- a/src/queries/sql/reports/getRevenueChart.ts
+++ b/src/queries/sql/reports/getRevenueChart.ts
@@ -123,7 +123,7 @@ async function clickhouseQuery(
website_revenue.created_at,
website_revenue.revenue
from website_revenue
- any inner join filtered_sessions
+ inner join filtered_sessions
on filtered_sessions.website_id = website_revenue.website_id
and filtered_sessions.session_id = website_revenue.session_id
where website_revenue.website_id = {websiteId:UUID}
diff --git a/src/queries/sql/reports/getRevenueMetrics.ts b/src/queries/sql/reports/getRevenueMetrics.ts
index ebb51a047..ea12b5764 100644
--- a/src/queries/sql/reports/getRevenueMetrics.ts
+++ b/src/queries/sql/reports/getRevenueMetrics.ts
@@ -210,8 +210,8 @@ async function relationalQuery(
we.utm_medium ilike '%ppc%' OR
we.utm_medium ilike '%retargeting%' OR
we.utm_medium ilike '%paid%' then 'paid' else 'organic' end AS prefix,
- we.referrer_domain,
- we.url_query,
+ coalesce(we.referrer_domain, '') as referrer_domain,
+ coalesce(we.url_query, '') as url_query,
we.utm_medium,
we.utm_source,
we.hostname,
@@ -293,7 +293,7 @@ async function clickhouseQuery(
website_revenue.created_at,
website_revenue.revenue
from website_revenue
- any inner join filtered_sessions
+ inner join filtered_sessions
on filtered_sessions.website_id = website_revenue.website_id
and filtered_sessions.session_id = website_revenue.session_id
where website_revenue.website_id = {websiteId:UUID}
@@ -312,7 +312,7 @@ async function clickhouseQuery(
filtered_sessions.country as "name",
sum(filtered_revenue.revenue) as "value"
from filtered_revenue
- any inner join filtered_sessions
+ inner join filtered_sessions
on filtered_sessions.website_id = filtered_revenue.website_id
and filtered_sessions.session_id = filtered_revenue.session_id
group by filtered_sessions.country
@@ -333,7 +333,7 @@ async function clickhouseQuery(
filtered_sessions.region as "name",
sum(filtered_revenue.revenue) as "value"
from filtered_revenue
- any inner join filtered_sessions
+ inner join filtered_sessions
on filtered_sessions.website_id = filtered_revenue.website_id
and filtered_sessions.session_id = filtered_revenue.session_id
group by 1, 2
@@ -354,7 +354,7 @@ async function clickhouseQuery(
website_revenue.session_id,
sum(website_revenue.revenue) as "value"
from website_revenue
- any inner join filtered_sessions
+ inner join filtered_sessions
on filtered_sessions.website_id = website_revenue.website_id
and filtered_sessions.session_id = website_revenue.session_id
where website_revenue.website_id = {websiteId:UUID}
@@ -409,7 +409,7 @@ async function clickhouseQuery(
website_revenue.session_id,
sum(website_revenue.revenue) as "value"
from website_revenue
- any inner join filtered_sessions
+ inner join filtered_sessions
on filtered_sessions.website_id = website_revenue.website_id
and filtered_sessions.session_id = website_revenue.session_id
where website_revenue.website_id = {websiteId:UUID}
diff --git a/src/queries/sql/reports/getRevenueStats.ts b/src/queries/sql/reports/getRevenueStats.ts
index aebc70031..dfb174002 100644
--- a/src/queries/sql/reports/getRevenueStats.ts
+++ b/src/queries/sql/reports/getRevenueStats.ts
@@ -112,7 +112,7 @@ async function clickhouseQuery(
filtered_revenue as (
select website_revenue.website_id, website_revenue.session_id, website_revenue.event_id, website_revenue.revenue
from website_revenue
- any inner join filtered_sessions
+ inner join filtered_sessions
on filtered_sessions.website_id = website_revenue.website_id
and filtered_sessions.session_id = website_revenue.session_id
where website_revenue.website_id = {websiteId:UUID}
diff --git a/src/queries/sql/sessions/getWebsiteSessions.ts b/src/queries/sql/sessions/getWebsiteSessions.ts
index 9bf0a232f..67d8f7323 100644
--- a/src/queries/sql/sessions/getWebsiteSessions.ts
+++ b/src/queries/sql/sessions/getWebsiteSessions.ts
@@ -75,7 +75,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
session.country,
session.region,
session.city
- order by max(website_event.created_at) desc
+ order by max(website_event.created_at) desc, session.session_id
`,
queryParams,
filters,
@@ -138,7 +138,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) {
${normalizedFilterQuery}
${searchQuery}
group by session_id
- order by lastAt desc
+ order by lastAt desc, id
`;
} else {
sql = `
@@ -168,7 +168,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) {
${normalizedFilterQuery}
${searchQuery}
group by session_id
- order by lastAt desc
+ order by lastAt desc, id
`;
}
diff --git a/tests/api/account.spec.ts b/tests/api/account.spec.ts
new file mode 100644
index 000000000..c9f5b4181
--- /dev/null
+++ b/tests/api/account.spec.ts
@@ -0,0 +1,340 @@
+import { expect, test } from './fixtures';
+import { login } from './helpers/auth';
+import { UNKNOWN_UUID } from './helpers/constants';
+import { createUser, deleteUser, uniqueName } from './helpers/entities';
+
+test.describe('Account', () => {
+ test('GET /api/me returns the session user', async ({ admin, user, seed }) => {
+ const response = await admin.get('/api/me');
+ const asUser = await user.get('/api/me');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ token: expect.any(String),
+ user: {
+ id: seed.admin.id,
+ username: seed.admin.username,
+ role: 'admin',
+ createdAt: expect.any(String),
+ isAdmin: true,
+ twoFactorRequired: expect.any(Boolean),
+ },
+ });
+ expect(response.body.user).not.toHaveProperty('password');
+ expect(response.body).not.toHaveProperty('apiKey');
+ expect(asUser.status).toBe(200);
+ expect(asUser.body.user).toMatchObject({ id: seed.user.id, role: 'user', isAdmin: false });
+ });
+
+ test('GET /api/me identifies API key callers', async ({ apiKey, tokens, seed }) => {
+ const response = await apiKey.get('/api/me');
+
+ expect(response.status).toBe(200);
+ expect(response.body.user.id).toBe(seed.admin.id);
+ expect(response.body.apiKey).toMatchObject({ id: tokens.apiKey.id, name: expect.any(String) });
+ });
+
+ test('GET /api/me requires authentication', async ({ api }) => {
+ const anonymous = await api.get('/api/me');
+ const garbage = await api.bearer('umami_not_a_real_key').get('/api/me');
+
+ expect(anonymous.status).toBe(401);
+ expect(garbage.status).toBe(401);
+ });
+
+ test.describe('API keys', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ const name = uniqueName('key');
+ let keyId = '';
+ let key = '';
+
+ test.afterAll(async ({ user }) => {
+ if (keyId) {
+ await user.del(`/api/me/api-keys/${keyId}`);
+ }
+ });
+
+ test('POST /api/me/api-keys creates a key', async ({ user }) => {
+ const response = await user.post('/api/me/api-keys', { name: ` ${name} ` });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ id: expect.any(String),
+ // Names are trimmed.
+ name,
+ keyPrefix: expect.any(String),
+ createdAt: expect.any(String),
+ key: expect.stringMatching(/^umami_/),
+ });
+ expect(response.body.key.startsWith(response.body.keyPrefix)).toBe(true);
+ expect(response.body).not.toHaveProperty('keyHash');
+
+ keyId = response.body.id;
+ key = response.body.key;
+ });
+
+ test('POST /api/me/api-keys validates the body', async ({ user }) => {
+ const empty = await user.post('/api/me/api-keys', { name: ' ' });
+ const missing = await user.post('/api/me/api-keys', {});
+ const tooLong = await user.post('/api/me/api-keys', { name: 'x'.repeat(256) });
+
+ expect(empty.status).toBe(400);
+ expect(missing.status).toBe(400);
+ expect(tooLong.status).toBe(400);
+ });
+
+ test('POST /api/me/api-keys rejects anonymous and API key callers', async ({ api, apiKey }) => {
+ const anonymous = await api.post('/api/me/api-keys', { name });
+ const viaApiKey = await apiKey.post('/api/me/api-keys', { name });
+
+ expect(anonymous.status).toBe(401);
+ expect(viaApiKey.status).toBe(401);
+ });
+
+ test('GET /api/me authenticates with the new key', async ({ api, seed }) => {
+ const response = await api.bearer(key).get('/api/me');
+
+ expect(response.status).toBe(200);
+ expect(response.body.user.id).toBe(seed.user.id);
+ expect(response.body.apiKey).toEqual({ id: keyId, name });
+ });
+
+ test('GET /api/me/api-keys lists the caller keys without secrets', async ({
+ user,
+ admin,
+ tokens,
+ }) => {
+ const response = await user.get('/api/me/api-keys');
+ const adminKeys = await admin.get('/api/me/api-keys');
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+
+ const created = response.body.find((k: any) => k.id === keyId);
+
+ expect(created).toMatchObject({
+ id: keyId,
+ name,
+ keyPrefix: expect.any(String),
+ createdAt: expect.any(String),
+ });
+ expect(created).toHaveProperty('lastUsedAt');
+ expect(created).not.toHaveProperty('key');
+ expect(created).not.toHaveProperty('keyHash');
+
+ // Keys are scoped to the caller: the admin sees the worker key, not the user key.
+ expect(adminKeys.status).toBe(200);
+ expect(adminKeys.body.map((k: any) => k.id)).toContain(tokens.apiKey.id);
+ expect(adminKeys.body.map((k: any) => k.id)).not.toContain(keyId);
+ });
+
+ test('GET /api/me/api-keys rejects anonymous and API key callers', async ({ api, apiKey }) => {
+ expect((await api.get('/api/me/api-keys')).status).toBe(401);
+ expect((await apiKey.get('/api/me/api-keys')).status).toBe(401);
+ });
+
+ test('DELETE /api/me/api-keys/{keyId} rejects unknown and foreign keys', async ({
+ admin,
+ user,
+ api,
+ apiKey,
+ }) => {
+ const foreign = await admin.del(`/api/me/api-keys/${keyId}`);
+ const unknown = await user.del(`/api/me/api-keys/${UNKNOWN_UUID}`);
+ const anonymous = await api.del(`/api/me/api-keys/${keyId}`);
+ const viaApiKey = await apiKey.del(`/api/me/api-keys/${keyId}`);
+
+ expect(foreign.status).toBe(404);
+ expect(unknown.status).toBe(404);
+ expect(anonymous.status).toBe(401);
+ expect(viaApiKey.status).toBe(401);
+ });
+
+ test('DELETE /api/me/api-keys/{keyId} revokes the key', async ({ user, api }) => {
+ const response = await user.del(`/api/me/api-keys/${keyId}`);
+ const list = await user.get('/api/me/api-keys');
+ const revoked = await api.bearer(key).get('/api/me');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+ expect(list.body.map((k: any) => k.id)).not.toContain(keyId);
+ expect(revoked.status).toBe(401);
+
+ keyId = '';
+ });
+ });
+
+ test.describe('Password', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let userId = '';
+ let username = '';
+ let password = '';
+ let token = '';
+ const newPassword = 'changed-password-1';
+
+ test.beforeAll(async ({ admin, api }) => {
+ const created = await createUser(admin);
+
+ userId = created.id;
+ username = created.username;
+ password = created.password;
+ token = await login(api, { username, password });
+ });
+
+ test.afterAll(async ({ admin }) => {
+ if (userId) {
+ await deleteUser(admin, userId);
+ }
+ });
+
+ test('POST /api/me/password validates the body', async ({ api }) => {
+ const client = api.bearer(token);
+ const short = await client.post('/api/me/password', {
+ currentPassword: password,
+ newPassword: 'short',
+ });
+ const missingCurrent = await client.post('/api/me/password', { newPassword });
+ const missingNew = await client.post('/api/me/password', { currentPassword: password });
+
+ expect(short.status).toBe(400);
+ expect(missingCurrent.status).toBe(400);
+ expect(missingNew.status).toBe(400);
+ });
+
+ test('POST /api/me/password rejects a wrong current password', async ({ api }) => {
+ const response = await api
+ .bearer(token)
+ .post('/api/me/password', { currentPassword: 'wrong-password', newPassword });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error.message).toBe('Current password is incorrect');
+ });
+
+ test('POST /api/me/password rejects anonymous and API key callers', async ({ api, apiKey }) => {
+ const body = { currentPassword: password, newPassword };
+
+ expect((await api.post('/api/me/password', body)).status).toBe(401);
+ expect((await apiKey.post('/api/me/password', body)).status).toBe(401);
+ });
+
+ test('POST /api/me/password changes the password and invalidates the session', async ({
+ api,
+ }) => {
+ const response = await api
+ .bearer(token)
+ .post('/api/me/password', { currentPassword: password, newPassword });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ id: userId, username, role: 'user' });
+ expect(response.body).not.toHaveProperty('password');
+
+ // Tokens are bound to the password hash, so the old session is rejected.
+ const stale = await api.bearer(token).get('/api/me');
+ const oldLogin = await api.post('/api/auth/login', { username, password });
+ const newToken = await login(api, { username, password: newPassword });
+ const fresh = await api.bearer(newToken).get('/api/me');
+
+ expect(stale.status).toBe(401);
+ expect(oldLogin.status).toBe(401);
+ expect(fresh.status).toBe(200);
+ expect(fresh.body.user.id).toBe(userId);
+ });
+ });
+
+ test('GET /api/me/teams lists the caller teams', async ({ user, viewer, seed }) => {
+ const response = await user.get('/api/me/teams');
+ const none = await viewer.get('/api/me/teams');
+
+ 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.count).toBeGreaterThanOrEqual(1);
+ expect(response.body.data.map((t: any) => t.id)).toContain(seed.team.id);
+ expect(none.status).toBe(200);
+ expect(none.body.data.map((t: any) => t.id)).not.toContain(seed.team.id);
+ });
+
+ test('GET /api/me/teams supports paging and sorting', async ({ user }) => {
+ const paged = await user.get('/api/me/teams', { params: { page: 1, pageSize: 1 } });
+ const byName = await user.get('/api/me/teams', {
+ params: { orderBy: 'name', sortDescending: 'true' },
+ });
+ const byCreated = await user.get('/api/me/teams', {
+ params: { orderBy: 'createdAt', sortDescending: 'false' },
+ });
+ const badSort = await user.get('/api/me/teams', { params: { sortDescending: 'yes' } });
+ const badPage = await user.get('/api/me/teams', { params: { page: 0 } });
+
+ expect(paged.status).toBe(200);
+ expect(paged.body.pageSize).toBe(1);
+ expect(paged.body.data.length).toBeLessThanOrEqual(1);
+ expect(byName.status).toBe(200);
+ expect(byCreated.status).toBe(200);
+ expect(badSort.status).toBe(400);
+ expect(badPage.status).toBe(400);
+ });
+
+ test('GET /api/me/teams accepts session and API key callers only', async ({ api, apiKey }) => {
+ const anonymous = await api.get('/api/me/teams');
+ const viaApiKey = await apiKey.get('/api/me/teams');
+
+ expect(anonymous.status).toBe(401);
+ expect(viaApiKey.status).toBe(200);
+ expect(viaApiKey.body).toHaveProperty('data');
+ });
+
+ test('GET /api/me/websites lists the caller websites', async ({ user, admin, seed }) => {
+ const response = await user.get('/api/me/websites');
+ const asAdmin = await admin.get('/api/me/websites');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ data: expect.any(Array),
+ count: expect.any(Number),
+ page: 1,
+ pageSize: expect.any(Number),
+ });
+
+ const website2 = response.body.data.find((w: any) => w.id === seed.website2.id);
+
+ expect(website2).toMatchObject({ name: seed.website2.name, domain: seed.website2.domain });
+ expect(website2).toHaveProperty('shareId');
+ // Only websites owned by the caller are listed by default.
+ expect(response.body.data.map((w: any) => w.id)).not.toContain(seed.website.id);
+ expect(asAdmin.body.data.map((w: any) => w.id)).toContain(seed.website.id);
+ });
+
+ test('GET /api/me/websites supports includeTeams, paging and sorting', async ({ user, seed }) => {
+ const withTeams = await user.get('/api/me/websites', { params: { includeTeams: 'true' } });
+ const paged = await user.get('/api/me/websites', {
+ params: { page: 1, pageSize: 1, orderBy: 'name', sortDescending: 'false' },
+ });
+ const badSort = await user.get('/api/me/websites', { params: { sortDescending: '1' } });
+
+ expect(withTeams.status).toBe(200);
+ expect(withTeams.body.data.map((w: any) => w.id)).toContain(seed.website2.id);
+ expect(paged.status).toBe(200);
+ expect(paged.body.pageSize).toBe(1);
+ expect(paged.body.data.length).toBeLessThanOrEqual(1);
+ expect(badSort.status).toBe(400);
+ });
+
+ test('GET /api/me/websites accepts session and API key callers only', async ({
+ api,
+ apiKey,
+ seed,
+ }) => {
+ const anonymous = await api.get('/api/me/websites');
+ const viaApiKey = await apiKey.get('/api/me/websites');
+
+ expect(anonymous.status).toBe(401);
+ expect(viaApiKey.status).toBe(200);
+ expect(viaApiKey.body.data.map((w: any) => w.id)).toContain(seed.website.id);
+ });
+});
diff --git a/tests/api/admin.spec.ts b/tests/api/admin.spec.ts
new file mode 100644
index 000000000..f580194a0
--- /dev/null
+++ b/tests/api/admin.spec.ts
@@ -0,0 +1,445 @@
+import { expect, test } from './fixtures';
+import { login } from './helpers/auth';
+import { UNKNOWN_UUID } from './helpers/constants';
+import { createTeam, createUser, deleteTeam, deleteUser } from './helpers/entities';
+
+test.describe('Admin', () => {
+ test.describe('Listings', () => {
+ test('GET /api/admin/users lists every user', async ({ admin, seed }) => {
+ const response = await admin.get('/api/admin/users');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ data: expect.any(Array),
+ count: expect.any(Number),
+ page: 1,
+ pageSize: expect.any(Number),
+ });
+
+ const ids = response.body.data.map((u: any) => u.id);
+
+ expect(ids).toContain(seed.admin.id);
+ expect(ids).toContain(seed.user.id);
+ expect(ids).toContain(seed.viewer.id);
+
+ const adminUser = response.body.data.find((u: any) => u.id === seed.admin.id);
+
+ expect(adminUser).toMatchObject({
+ username: seed.admin.username,
+ role: 'admin',
+ twoFactorRequired: expect.any(Boolean),
+ _count: { websites: expect.any(Number) },
+ });
+ expect(adminUser).not.toHaveProperty('password');
+ });
+
+ test('GET /api/admin/users supports search, sorting and paging', async ({ admin, seed }) => {
+ const search = await admin.get('/api/admin/users', { params: { search: 'api-' } });
+ const byUsername = await admin.get('/api/admin/users', {
+ params: { orderBy: 'username', sortDescending: 'false' },
+ });
+ const byRole = await admin.get('/api/admin/users', {
+ params: { orderBy: 'role', sortDescending: 'true' },
+ });
+ const byCreated = await admin.get('/api/admin/users', { params: { orderBy: 'createdAt' } });
+ const paged = await admin.get('/api/admin/users', { params: { page: 1, pageSize: 2 } });
+ const badSort = await admin.get('/api/admin/users', { params: { sortDescending: 'yes' } });
+ const badPage = await admin.get('/api/admin/users', { params: { pageSize: 'ten' } });
+
+ expect(search.status).toBe(200);
+ expect(search.body.data.length).toBeGreaterThanOrEqual(2);
+ expect(search.body.data.map((u: any) => u.id)).toContain(seed.user.id);
+ expect(search.body.data.every((u: any) => u.username.includes('api-'))).toBe(true);
+ expect(search.body.data.map((u: any) => u.id)).not.toContain(seed.admin.id);
+
+ expect(byUsername.status).toBe(200);
+ const usernames = byUsername.body.data.map((u: any) => u.username);
+ expect(usernames).toEqual([...usernames].sort());
+
+ expect(byRole.status).toBe(200);
+ expect(byCreated.status).toBe(200);
+ expect(paged.status).toBe(200);
+ expect(paged.body.pageSize).toBe(2);
+ expect(paged.body.data.length).toBeLessThanOrEqual(2);
+ expect(badSort.status).toBe(400);
+ expect(badPage.status).toBe(400);
+ });
+
+ test('GET /api/admin/users requires an admin session', async ({
+ api,
+ user,
+ viewer,
+ apiKey,
+ }) => {
+ expect((await api.get('/api/admin/users')).status).toBe(401);
+ expect((await user.get('/api/admin/users')).status).toBe(401);
+ expect((await viewer.get('/api/admin/users')).status).toBe(401);
+ // API keys are blocked on /api/admin/* even for admins.
+ expect((await apiKey.get('/api/admin/users')).status).toBe(401);
+ });
+
+ test('GET /api/admin/websites lists every website with its owner', async ({ admin, seed }) => {
+ const response = await admin.get('/api/admin/websites');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ data: expect.any(Array), count: expect.any(Number) });
+
+ const ids = response.body.data.map((w: any) => w.id);
+
+ expect(ids).toContain(seed.website.id);
+ expect(ids).toContain(seed.website2.id);
+
+ const website2 = response.body.data.find((w: any) => w.id === seed.website2.id);
+
+ expect(website2).toMatchObject({
+ name: seed.website2.name,
+ domain: seed.website2.domain,
+ user: { id: seed.user.id, username: seed.user.username },
+ });
+ expect(website2).toHaveProperty('shareId');
+ });
+
+ test('GET /api/admin/websites supports search, sorting and paging', async ({ admin, seed }) => {
+ const byDomain = await admin.get('/api/admin/websites', {
+ params: { search: seed.website2.domain },
+ });
+ const byName = await admin.get('/api/admin/websites', {
+ params: { search: seed.website.name },
+ });
+ const sortedName = await admin.get('/api/admin/websites', {
+ params: { orderBy: 'name', sortDescending: 'false', pageSize: 5 },
+ });
+ const sortedDomain = await admin.get('/api/admin/websites', {
+ params: { orderBy: 'domain', sortDescending: 'true' },
+ });
+ const sortedCreated = await admin.get('/api/admin/websites', {
+ params: { orderBy: 'createdAt' },
+ });
+ const badSort = await admin.get('/api/admin/websites', { params: { sortDescending: 'x' } });
+
+ expect(byDomain.status).toBe(200);
+ expect(byDomain.body.data.map((w: any) => w.id)).toContain(seed.website2.id);
+ expect(byDomain.body.data.map((w: any) => w.id)).not.toContain(seed.website.id);
+ expect(byName.status).toBe(200);
+ expect(byName.body.data.map((w: any) => w.id)).toContain(seed.website.id);
+
+ expect(sortedName.status).toBe(200);
+ expect(sortedName.body.pageSize).toBe(5);
+ expect(sortedName.body.data.length).toBeLessThanOrEqual(5);
+ expect(sortedDomain.status).toBe(200);
+ expect(sortedCreated.status).toBe(200);
+ expect(badSort.status).toBe(400);
+ });
+
+ test('GET /api/admin/websites requires an admin session', async ({ api, user, apiKey }) => {
+ expect((await api.get('/api/admin/websites')).status).toBe(401);
+ expect((await user.get('/api/admin/websites')).status).toBe(401);
+ expect((await apiKey.get('/api/admin/websites')).status).toBe(401);
+ });
+
+ test('GET /api/admin/teams lists every team with members', async ({ admin, seed }) => {
+ const response = await admin.get('/api/admin/teams');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ data: expect.any(Array), count: expect.any(Number) });
+
+ const team = response.body.data.find((t: any) => t.id === seed.team.id);
+
+ expect(team).toMatchObject({
+ name: seed.team.name,
+ members: expect.any(Array),
+ _count: { websites: expect.any(Number), members: expect.any(Number) },
+ });
+ expect(team.members.map((m: any) => m.userId)).toContain(seed.user.id);
+ expect(team.members[0]).toMatchObject({
+ role: expect.any(String),
+ user: { id: expect.any(String), username: expect.any(String) },
+ });
+ });
+
+ test('GET /api/admin/teams supports search, sorting and paging', async ({ admin, seed }) => {
+ const search = await admin.get('/api/admin/teams', { params: { search: seed.team.name } });
+ const none = await admin.get('/api/admin/teams', {
+ params: { search: 'no-such-team-name-xyz' },
+ });
+ const sorted = await admin.get('/api/admin/teams', {
+ params: { orderBy: 'name', sortDescending: 'true', page: 1, pageSize: 1 },
+ });
+ const badSort = await admin.get('/api/admin/teams', { params: { sortDescending: 'nope' } });
+
+ expect(search.status).toBe(200);
+ expect(search.body.data.map((t: any) => t.id)).toContain(seed.team.id);
+ expect(none.status).toBe(200);
+ expect(none.body.data).toEqual([]);
+ expect(none.body.count).toBe(0);
+ expect(sorted.status).toBe(200);
+ expect(sorted.body.pageSize).toBe(1);
+ expect(sorted.body.data.length).toBe(1);
+ expect(badSort.status).toBe(400);
+ });
+
+ test('GET /api/admin/teams requires an admin session', async ({ api, user, apiKey }) => {
+ expect((await api.get('/api/admin/teams')).status).toBe(401);
+ expect((await user.get('/api/admin/teams')).status).toBe(401);
+ expect((await apiKey.get('/api/admin/teams')).status).toBe(401);
+ });
+ });
+
+ test.describe('Global 2FA enforcement', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ test.afterAll(async ({ admin }) => {
+ // Never leave the global requirement enabled for other specs.
+ await admin.post('/api/admin/2fa/global', { required: false });
+ });
+
+ test('POST /api/admin/2fa/global validates the body', async ({ admin }) => {
+ const stringValue = await admin.post('/api/admin/2fa/global', { required: 'true' });
+ const missing = await admin.post('/api/admin/2fa/global', {});
+
+ expect(stringValue.status).toBe(400);
+ expect(missing.status).toBe(400);
+ });
+
+ test('POST /api/admin/2fa/global requires an admin session', async ({ api, user, apiKey }) => {
+ expect((await api.post('/api/admin/2fa/global', { required: false })).status).toBe(401);
+ expect((await user.post('/api/admin/2fa/global', { required: false })).status).toBe(401);
+ expect((await apiKey.post('/api/admin/2fa/global', { required: false })).status).toBe(401);
+ });
+
+ test('POST /api/admin/2fa/global toggles the global requirement', async ({ admin, user }) => {
+ const before = await user.get('/api/2fa/status');
+
+ expect(before.status).toBe(200);
+ expect(before.body.globalRequired).toBe(false);
+
+ const enabled = await admin.post('/api/admin/2fa/global', { required: true });
+
+ try {
+ expect(enabled.status).toBe(200);
+ expect(enabled.body).toEqual({ ok: true, required: true });
+
+ const status = await user.get('/api/2fa/status');
+
+ expect(status.status).toBe(200);
+ expect(status.body).toMatchObject({
+ isRequired: true,
+ requiredReason: 'global',
+ isConfigured: true,
+ globalRequired: true,
+ });
+ } finally {
+ const disabled = await admin.post('/api/admin/2fa/global', { required: false });
+
+ expect(disabled.status).toBe(200);
+ expect(disabled.body).toEqual({ ok: true, required: false });
+ }
+
+ const after = await user.get('/api/2fa/status');
+
+ expect(after.body).toMatchObject({ globalRequired: false });
+ expect(after.body.requiredReason).not.toBe('global');
+ });
+ });
+
+ test.describe('Team 2FA enforcement', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let teamId = '';
+
+ test.beforeAll(async ({ admin }) => {
+ teamId = (await createTeam(admin)).id;
+ });
+
+ test.afterAll(async ({ admin }) => {
+ if (teamId) {
+ await admin.post(`/api/admin/teams/${teamId}/2fa`, { required: false });
+ await deleteTeam(admin, teamId);
+ }
+ });
+
+ test('POST /api/admin/teams/{teamId}/2fa validates the body', async ({ admin }) => {
+ const stringValue = await admin.post(`/api/admin/teams/${teamId}/2fa`, { required: 'true' });
+ const missing = await admin.post(`/api/admin/teams/${teamId}/2fa`, {});
+
+ expect(stringValue.status).toBe(400);
+ expect(missing.status).toBe(400);
+ });
+
+ test('POST /api/admin/teams/{teamId}/2fa requires an admin session', async ({
+ api,
+ user,
+ apiKey,
+ }) => {
+ const body = { required: false };
+
+ expect((await api.post(`/api/admin/teams/${teamId}/2fa`, body)).status).toBe(401);
+ expect((await user.post(`/api/admin/teams/${teamId}/2fa`, body)).status).toBe(401);
+ expect((await apiKey.post(`/api/admin/teams/${teamId}/2fa`, body)).status).toBe(401);
+ });
+
+ test('POST /api/admin/teams/{teamId}/2fa toggles the team requirement', async ({ admin }) => {
+ const enabled = await admin.post(`/api/admin/teams/${teamId}/2fa`, { required: true });
+
+ try {
+ expect(enabled.status).toBe(200);
+ expect(enabled.body).toEqual({ ok: true, teamId, twoFactorRequired: true });
+
+ // The admin owns the throwaway team, so their status now reports the team reason.
+ const status = await admin.get('/api/2fa/status');
+
+ expect(status.status).toBe(200);
+ expect(status.body).toMatchObject({ isRequired: true, requiredReason: 'team' });
+ } finally {
+ const disabled = await admin.post(`/api/admin/teams/${teamId}/2fa`, { required: false });
+
+ expect(disabled.status).toBe(200);
+ expect(disabled.body).toEqual({ ok: true, teamId, twoFactorRequired: false });
+ }
+
+ const after = await admin.get('/api/2fa/status');
+
+ expect(after.body.requiredReason).not.toBe('team');
+ });
+ });
+
+ test.describe('User 2FA enforcement', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let userId = '';
+ let userToken = '';
+
+ test.beforeAll(async ({ admin, api }) => {
+ const created = await createUser(admin);
+
+ userId = created.id;
+ userToken = await login(api, { username: created.username, password: created.password });
+ });
+
+ test.afterAll(async ({ admin }) => {
+ if (userId) {
+ await deleteUser(admin, userId);
+ }
+ });
+
+ test('GET /api/admin/users/{userId}/2fa reports whether 2FA is enabled', async ({ admin }) => {
+ const response = await admin.get(`/api/admin/users/${userId}/2fa`);
+ const unknown = await admin.get(`/api/admin/users/${UNKNOWN_UUID}/2fa`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ isEnabled: false });
+ // Unknown users simply have no 2FA record.
+ expect(unknown.status).toBe(200);
+ expect(unknown.body).toEqual({ isEnabled: false });
+ });
+
+ test('GET /api/admin/users/{userId}/2fa requires an admin session', async ({
+ api,
+ user,
+ apiKey,
+ }) => {
+ expect((await api.get(`/api/admin/users/${userId}/2fa`)).status).toBe(401);
+ expect((await user.get(`/api/admin/users/${userId}/2fa`)).status).toBe(401);
+ expect((await apiKey.get(`/api/admin/users/${userId}/2fa`)).status).toBe(401);
+ });
+
+ test('POST /api/admin/users/{userId}/2fa validates the body', async ({ admin }) => {
+ const stringValue = await admin.post(`/api/admin/users/${userId}/2fa`, { required: 'true' });
+ const missing = await admin.post(`/api/admin/users/${userId}/2fa`, {});
+
+ expect(stringValue.status).toBe(400);
+ expect(missing.status).toBe(400);
+ });
+
+ test('POST /api/admin/users/{userId}/2fa requires an admin session', async ({
+ api,
+ user,
+ apiKey,
+ }) => {
+ const body = { required: false };
+
+ expect((await api.post(`/api/admin/users/${userId}/2fa`, body)).status).toBe(401);
+ expect((await user.post(`/api/admin/users/${userId}/2fa`, body)).status).toBe(401);
+ expect((await apiKey.post(`/api/admin/users/${userId}/2fa`, body)).status).toBe(401);
+ });
+
+ test('POST /api/admin/users/{userId}/2fa toggles the user requirement', async ({
+ admin,
+ api,
+ }) => {
+ const throwaway = api.bearer(userToken);
+ const enabled = await admin.post(`/api/admin/users/${userId}/2fa`, { required: true });
+
+ expect(enabled.status).toBe(200);
+ expect(enabled.body).toEqual({ ok: true, userId, twoFactorRequired: true });
+
+ const status = await throwaway.get('/api/2fa/status');
+ const me = await throwaway.get('/api/me');
+ const listed = await admin.get('/api/admin/users', { params: { search: userId } });
+
+ expect(status.body).toMatchObject({
+ isEnabled: false,
+ isRequired: true,
+ requiredReason: 'user',
+ globalRequired: false,
+ });
+ expect(me.body.user.twoFactorRequired).toBe(true);
+ expect(listed.status).toBe(200);
+
+ const disabled = await admin.post(`/api/admin/users/${userId}/2fa`, { required: false });
+
+ expect(disabled.status).toBe(200);
+ expect(disabled.body).toEqual({ ok: true, userId, twoFactorRequired: false });
+
+ const after = await throwaway.get('/api/2fa/status');
+
+ expect(after.body).toMatchObject({ isRequired: false, requiredReason: null });
+ });
+
+ test('DELETE /api/admin/users/{userId}/2fa resets the user 2FA state', async ({
+ admin,
+ api,
+ user,
+ apiKey,
+ }) => {
+ const throwaway = api.bearer(userToken);
+
+ // Leave a pending setup behind so the reset has something to clear.
+ const initiated = await throwaway.post('/api/2fa/setup/initiate');
+
+ expect(initiated.status).toBe(200);
+
+ const denied = await user.del(`/api/admin/users/${userId}/2fa`);
+ const viaApiKey = await apiKey.del(`/api/admin/users/${userId}/2fa`);
+ const response = await admin.del(`/api/admin/users/${userId}/2fa`);
+ const again = await admin.del(`/api/admin/users/${userId}/2fa`);
+
+ expect(denied.status).toBe(401);
+ expect(viaApiKey.status).toBe(401);
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ ok: true,
+ userId,
+ reset: {
+ twoFactorAuth: 1,
+ backupCodes: expect.any(Number),
+ otpUsed: expect.any(Number),
+ rateLimit: expect.any(Number),
+ },
+ });
+ expect(again.status).toBe(200);
+ expect(again.body.reset).toEqual({
+ twoFactorAuth: 0,
+ backupCodes: 0,
+ otpUsed: 0,
+ rateLimit: 0,
+ });
+
+ // The pending setup is gone.
+ const confirm = await throwaway.post('/api/2fa/setup/confirm', { token: '000000' });
+
+ expect(confirm.status).toBe(400);
+ expect(confirm.body.error.code).toBe('two-factor-error-no-pending-setup');
+ });
+ });
+});
diff --git a/tests/api/analytics.spec.ts b/tests/api/analytics.spec.ts
new file mode 100644
index 000000000..2ffdc3b69
--- /dev/null
+++ b/tests/api/analytics.spec.ts
@@ -0,0 +1,469 @@
+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);
+ });
+});
diff --git a/tests/api/annotations.spec.ts b/tests/api/annotations.spec.ts
new file mode 100644
index 000000000..af781e0be
--- /dev/null
+++ b/tests/api/annotations.spec.ts
@@ -0,0 +1,167 @@
+import { expect, test } from './fixtures';
+import { UNKNOWN_UUID } from './helpers/constants';
+import { createWebsite, deleteWebsite, uniqueName } from './helpers/entities';
+
+test.describe('Annotations', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ const date = new Date('2026-01-15T12:00:00.000Z');
+
+ let websiteId = '';
+ let annotationId = '';
+ let base = '';
+
+ test.beforeAll(async ({ admin }) => {
+ websiteId = (await createWebsite(admin)).id;
+ base = `/api/websites/${websiteId}/annotations`;
+ });
+
+ test.afterAll(async ({ admin }) => {
+ if (websiteId) {
+ await deleteWebsite(admin, websiteId);
+ }
+ });
+
+ test('POST /api/websites/{websiteId}/annotations creates an annotation', async ({
+ admin,
+ seed,
+ }) => {
+ const note = uniqueName('note');
+ const response = await admin.post(base, { date: date.toISOString(), note });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ id: expect.any(String),
+ websiteId,
+ userId: seed.admin.id,
+ date: date.toISOString(),
+ allDay: true,
+ note,
+ createdAt: expect.any(String),
+ });
+
+ annotationId = response.body.id;
+ });
+
+ test('POST /api/websites/{websiteId}/annotations validates and requires permission', async ({
+ admin,
+ user,
+ viewer,
+ api,
+ }) => {
+ const missingNote = await admin.post(base, { date: date.toISOString() });
+ const emptyNote = await admin.post(base, { date: date.toISOString(), note: '' });
+ const badDate = await admin.post(base, { date: 'not-a-date', note: 'x' });
+ const tooLong = await admin.post(base, { date: date.toISOString(), note: 'x'.repeat(501) });
+ const notOwner = await user.post(base, { date: date.toISOString(), note: 'x' });
+ const denied = await viewer.post(base, { date: date.toISOString(), note: 'x' });
+ const anonymous = await api.post(base, { date: date.toISOString(), note: 'x' });
+
+ expect(missingNote.status).toBe(400);
+ expect(emptyNote.status).toBe(400);
+ expect(badDate.status).toBe(400);
+ expect(tooLong.status).toBe(400);
+ expect(notOwner.status).toBe(401);
+ expect(denied.status).toBe(401);
+ expect(anonymous.status).toBe(401);
+ });
+
+ test('GET /api/websites/{websiteId}/annotations lists annotations', async ({
+ admin,
+ viewer,
+ share,
+ seed,
+ }) => {
+ const response = await admin.get(base);
+ const inRange = await admin.get(base, {
+ params: { startAt: date.getTime() - 1000, endAt: date.getTime() + 1000 },
+ });
+ const outOfRange = await admin.get(base, {
+ params: { startAt: date.getTime() + 86_400_000, endAt: date.getTime() + 2 * 86_400_000 },
+ });
+ const search = await admin.get(base, { params: { search: 'zzz-no-such-note' } });
+ const paged = await admin.get(base, { params: { page: 1, pageSize: 1 } });
+ const denied = await viewer.get(base);
+ const shared = await (await share()).get(`/api/websites/${seed.website.id}/annotations`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ data: expect.any(Array),
+ count: 1,
+ page: 1,
+ pageSize: expect.any(Number),
+ });
+ expect(response.body.data[0]).toMatchObject({ id: annotationId, websiteId });
+ expect(inRange.body.data.map((a: any) => a.id)).toEqual([annotationId]);
+ expect(outOfRange.body.data).toHaveLength(0);
+ expect(search.body.data).toHaveLength(0);
+ expect(paged.body.pageSize).toBe(1);
+ expect(denied.status).toBe(401);
+ expect(shared.status).toBe(200);
+ });
+
+ test('GET /api/websites/{websiteId}/annotations/{annotationId} returns an annotation', async ({
+ admin,
+ viewer,
+ }) => {
+ const response = await admin.get(`${base}/${annotationId}`);
+ const denied = await viewer.get(`${base}/${annotationId}`);
+ const unknown = await admin.get(`${base}/${UNKNOWN_UUID}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ id: annotationId, websiteId, allDay: true });
+ expect(denied.status).toBe(401);
+ expect(unknown.status).toBe(404);
+ });
+
+ test('POST /api/websites/{websiteId}/annotations/{annotationId} updates an annotation', async ({
+ admin,
+ user,
+ }) => {
+ const note = uniqueName('updated');
+ const newDate = new Date('2026-02-01T08:30:00.000Z');
+ const response = await admin.post(`${base}/${annotationId}`, {
+ date: newDate.toISOString(),
+ note,
+ allDay: false,
+ });
+ const invalid = await admin.post(`${base}/${annotationId}`, { date: newDate.toISOString() });
+ const unknown = await admin.post(`${base}/${UNKNOWN_UUID}`, {
+ date: newDate.toISOString(),
+ note,
+ });
+ const denied = await user.post(`${base}/${annotationId}`, {
+ date: newDate.toISOString(),
+ note,
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ id: annotationId,
+ date: newDate.toISOString(),
+ note,
+ allDay: false,
+ });
+ expect(invalid.status).toBe(400);
+ expect(unknown.status).toBe(404);
+ expect(denied.status).toBe(401);
+ });
+
+ test('DELETE /api/websites/{websiteId}/annotations/{annotationId} deletes an annotation', async ({
+ admin,
+ user,
+ }) => {
+ const denied = await user.del(`${base}/${annotationId}`);
+ const response = await admin.del(`${base}/${annotationId}`);
+ const gone = await admin.get(`${base}/${annotationId}`);
+ const again = await admin.del(`${base}/${annotationId}`);
+
+ expect(denied.status).toBe(401);
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+ expect(gone.status).toBe(404);
+ expect(again.status).toBe(404);
+
+ annotationId = '';
+ });
+});
diff --git a/tests/api/auth.spec.ts b/tests/api/auth.spec.ts
new file mode 100644
index 000000000..5606c9e04
--- /dev/null
+++ b/tests/api/auth.spec.ts
@@ -0,0 +1,129 @@
+import { expect, test } from './fixtures';
+import { login } from './helpers/auth';
+
+test.describe('Auth', () => {
+ test('POST /api/auth/login returns a token and the user', async ({ api, seed }) => {
+ const response = await api.post('/api/auth/login', {
+ username: seed.admin.username,
+ password: seed.admin.password,
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ token: expect.any(String),
+ user: {
+ id: seed.admin.id,
+ username: seed.admin.username,
+ role: 'admin',
+ createdAt: expect.any(String),
+ isAdmin: true,
+ teams: expect.any(Array),
+ },
+ });
+ expect(response.body).not.toHaveProperty('requiresTwoFactor');
+ expect(response.body.user).not.toHaveProperty('password');
+ });
+
+ test('POST /api/auth/login matches usernames case-insensitively', async ({ api, seed }) => {
+ const response = await api.post('/api/auth/login', {
+ username: seed.user.username.toUpperCase(),
+ password: seed.user.password,
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.token).toEqual(expect.any(String));
+ expect(response.body.user).toMatchObject({ id: seed.user.id, role: 'user', isAdmin: false });
+ // The response echoes the submitted username verbatim; the lookup itself is case-insensitive.
+ expect(response.body.user.username.toLowerCase()).toBe(seed.user.username);
+ expect(response.body.user.teams.map((t: any) => t.id)).toContain(seed.team.id);
+
+ // The session resolves to the stored (lowercase) username.
+ const me = await api.bearer(response.body.token).get('/api/me');
+
+ expect(me.status).toBe(200);
+ expect(me.body.user).toMatchObject({ id: seed.user.id, username: seed.user.username });
+ });
+
+ test('POST /api/auth/login rejects bad credentials', async ({ api, seed }) => {
+ const wrongPassword = await api.post('/api/auth/login', {
+ username: seed.admin.username,
+ password: 'not-the-password',
+ });
+ const unknownUser = await api.post('/api/auth/login', {
+ username: 'no-such-user',
+ password: seed.admin.password,
+ });
+
+ expect(wrongPassword.status).toBe(401);
+ expect(wrongPassword.body.error).toMatchObject({
+ code: 'incorrect-username-password',
+ status: 401,
+ });
+ expect(unknownUser.status).toBe(401);
+ expect(unknownUser.body.error.code).toBe('incorrect-username-password');
+ });
+
+ test('POST /api/auth/login validates the body', async ({ api, seed }) => {
+ const missingPassword = await api.post('/api/auth/login', { username: seed.admin.username });
+ const missingUsername = await api.post('/api/auth/login', { password: seed.admin.password });
+ const empty = await api.post('/api/auth/login', {});
+
+ expect(missingPassword.status).toBe(400);
+ expect(missingUsername.status).toBe(400);
+ expect(empty.status).toBe(400);
+ expect(empty.body.error).toMatchObject({ status: 400 });
+ });
+
+ test('POST /api/auth/verify returns the current session', async ({ admin, user, seed }) => {
+ const response = await admin.post('/api/auth/verify');
+ const asUser = await user.post('/api/auth/verify');
+
+ expect(response.status).toBe(200);
+ // The body is the flat user record plus its teams.
+ expect(response.body).toMatchObject({
+ id: seed.admin.id,
+ username: seed.admin.username,
+ role: 'admin',
+ createdAt: expect.any(String),
+ isAdmin: true,
+ twoFactorRequired: expect.any(Boolean),
+ teams: expect.any(Array),
+ });
+ expect(response.body).not.toHaveProperty('password');
+ expect(asUser.status).toBe(200);
+ expect(asUser.body.id).toBe(seed.user.id);
+ expect(asUser.body.teams.map((t: any) => t.id)).toContain(seed.team.id);
+ });
+
+ test('POST /api/auth/verify rejects anonymous, invalid and API key callers', async ({
+ api,
+ apiKey,
+ }) => {
+ const anonymous = await api.post('/api/auth/verify');
+ const garbage = await api.bearer('not-a-token').post('/api/auth/verify');
+ const viaApiKey = await apiKey.post('/api/auth/verify');
+
+ expect(anonymous.status).toBe(401);
+ expect(garbage.status).toBe(401);
+ // API keys are blocked on /api/auth/*.
+ expect(viaApiKey.status).toBe(401);
+ });
+
+ test('POST /api/auth/logout acknowledges the logout', async ({ api, seed }) => {
+ // Use a dedicated session so the shared worker tokens are never touched.
+ const token = await login(api, seed.viewer);
+ const response = await api.bearer(token).post('/api/auth/logout');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+ // Without Redis the stateless token remains valid, so no follow-up 401 is asserted.
+ });
+
+ test('POST /api/auth/logout requires a session', async ({ api, apiKey }) => {
+ const anonymous = await api.post('/api/auth/logout');
+ const viaApiKey = await apiKey.post('/api/auth/logout');
+
+ expect(anonymous.status).toBe(401);
+ expect(viaApiKey.status).toBe(401);
+ });
+});
diff --git a/tests/api/boards.spec.ts b/tests/api/boards.spec.ts
new file mode 100644
index 000000000..d5f051f0d
--- /dev/null
+++ b/tests/api/boards.spec.ts
@@ -0,0 +1,327 @@
+import { expect, test } from './fixtures';
+import { UNKNOWN_UUID } from './helpers/constants';
+import { uniqueName } from './helpers/entities';
+
+test.describe('Boards', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let boardId = '';
+ let teamBoardId = '';
+ let shareId = '';
+ /** Every board created by this file; ids are pushed before any assertion so cleanup always runs. */
+ const createdBoardIds: string[] = [];
+
+ test.afterAll(async ({ admin }) => {
+ for (const id of createdBoardIds) {
+ await admin.del(`/api/boards/${id}`);
+ }
+ });
+
+ test('POST /api/boards creates a board for the current user', async ({ admin, seed }) => {
+ const name = uniqueName('board');
+ const response = await admin.post('/api/boards', {
+ type: 'website',
+ name,
+ description: 'created by the api suite',
+ parameters: { websiteId: seed.website.id, rows: [] },
+ });
+
+ boardId = response.body?.id ?? '';
+ createdBoardIds.push(boardId);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ id: expect.any(String),
+ type: 'website',
+ name,
+ description: 'created by the api suite',
+ parameters: { websiteId: seed.website.id, rows: [] },
+ userId: seed.admin.id,
+ teamId: null,
+ createdAt: expect.any(String),
+ });
+ });
+
+ test('POST /api/boards creates a board for a team', async ({ admin, seed }) => {
+ const response = await admin.post('/api/boards', {
+ type: 'mixed',
+ name: uniqueName('team-board'),
+ description: '',
+ teamId: seed.team.id,
+ parameters: {},
+ });
+
+ teamBoardId = response.body?.id ?? '';
+ createdBoardIds.push(teamBoardId);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ type: 'mixed', teamId: seed.team.id, userId: null });
+ });
+
+ test('POST /api/boards validates the body', async ({ admin, user, seed }) => {
+ const dashboard = await admin.post('/api/boards', {
+ type: 'dashboard',
+ name: 'x',
+ description: '',
+ });
+ const longName = await admin.post('/api/boards', {
+ type: 'website',
+ name: 'x'.repeat(101),
+ description: '',
+ });
+ const missingName = await admin.post('/api/boards', { type: 'website', description: '' });
+ const inaccessible = await user.post('/api/boards', {
+ type: 'website',
+ name: 'x',
+ description: '',
+ parameters: { websiteId: seed.website.id },
+ });
+
+ expect(dashboard.status).toBe(400);
+ expect(longName.status).toBe(400);
+ expect(missingName.status).toBe(400);
+ expect(inaccessible.status).toBe(400);
+ expect(inaccessible.body.error.message).toBe('Board contains inaccessible entities.');
+ });
+
+ test('POST /api/boards requires a user that can create boards', async ({
+ api,
+ viewer,
+ user,
+ seed,
+ }) => {
+ const body = { type: 'mixed', name: uniqueName('board'), description: '' };
+ const anonymous = await api.post('/api/boards', body);
+ const viewOnly = await viewer.post('/api/boards', body);
+ const notMember = await viewer.post('/api/boards', { ...body, teamId: seed.team.id });
+ // Team members hold website:create and can therefore create team boards.
+ const member = await user.post('/api/boards', { ...body, teamId: seed.team.id });
+
+ if (member.body?.id) {
+ createdBoardIds.push(member.body.id);
+ }
+
+ expect(anonymous.status).toBe(401);
+ expect(viewOnly.status).toBe(401);
+ expect(notMember.status).toBe(401);
+ expect(member.status).toBe(200);
+ expect(member.body).toMatchObject({ teamId: seed.team.id, userId: null });
+ });
+
+ test('GET /api/boards lists the caller own boards', async ({ admin, user, seed }) => {
+ const response = await admin.get('/api/boards');
+ const search = await admin.get('/api/boards', {
+ params: { search: 'zzz-no-such-board', orderBy: 'name', sortDescending: 'true' },
+ });
+ const invalidSort = await admin.get('/api/boards', { params: { sortDescending: 'yes' } });
+ const other = await user.get('/api/boards');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ data: expect.any(Array), count: expect.any(Number) });
+ expect(response.body.data.map((b: any) => b.id)).toContain(boardId);
+ // Team boards and the personal dashboard are not part of the personal list.
+ expect(response.body.data.map((b: any) => b.id)).not.toContain(teamBoardId);
+ expect(response.body.data.map((b: any) => b.id)).not.toContain(seed.admin.id);
+ expect(search.status).toBe(200);
+ expect(search.body.data).toHaveLength(0);
+ expect(invalidSort.status).toBe(400);
+ expect(other.body.data.map((b: any) => b.id)).not.toContain(boardId);
+ });
+
+ test('GET /api/boards/{boardId} returns a board', async ({ admin, user, viewer, api, seed }) => {
+ const response = await admin.get(`/api/boards/${boardId}`);
+ const teamMember = await user.get(`/api/boards/${teamBoardId}`);
+ const denied = await viewer.get(`/api/boards/${boardId}`);
+ const anonymous = await api.get(`/api/boards/${boardId}`);
+ const unknown = await user.get(`/api/boards/${UNKNOWN_UUID}`);
+ const adminUnknown = await admin.get(`/api/boards/${UNKNOWN_UUID}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ id: boardId,
+ type: 'website',
+ parameters: { websiteId: seed.website.id },
+ });
+ expect(teamMember.status).toBe(200);
+ expect(teamMember.body.id).toBe(teamBoardId);
+ expect(denied.status).toBe(401);
+ expect(anonymous.status).toBe(401);
+ // Non-admins are refused before the lookup; admins bypass the check and get null.
+ expect(unknown.status).toBe(401);
+ expect(adminUnknown.status).toBe(200);
+ expect(adminUnknown.body).toBeNull();
+ });
+
+ test('POST /api/boards/{boardId} updates a board', async ({ admin, user, viewer, seed }) => {
+ const name = uniqueName('renamed');
+ const response = await admin.post(`/api/boards/${boardId}`, {
+ name,
+ description: 'updated',
+ parameters: { websiteId: seed.website.id, rows: [{ id: 'row-1', columns: [] }] },
+ });
+ const longName = await admin.post(`/api/boards/${boardId}`, { name: 'x'.repeat(201) });
+ const denied = await viewer.post(`/api/boards/${boardId}`, { name });
+ const member = await user.post(`/api/boards/${teamBoardId}`, { name });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ id: boardId,
+ name,
+ description: 'updated',
+ parameters: { websiteId: seed.website.id, rows: [{ id: 'row-1', columns: [] }] },
+ });
+ expect(longName.status).toBe(400);
+ expect(denied.status).toBe(401);
+ // Team members hold website:update and can rename team boards.
+ expect(member.status).toBe(200);
+ expect(member.body).toMatchObject({ id: teamBoardId, name });
+ });
+
+ test('POST /api/boards/{boardId}/clone copies a board', async ({ admin, user, viewer, seed }) => {
+ const name = uniqueName('clone');
+ const response = await admin.post(`/api/boards/${boardId}/clone`, { name });
+ const defaults = await admin.post(`/api/boards/${boardId}/clone`, {});
+ const denied = await viewer.post(`/api/boards/${boardId}/clone`, { name });
+ const unknown = await user.post(`/api/boards/${UNKNOWN_UUID}/clone`, { name });
+
+ for (const clone of [response, defaults]) {
+ if (clone.body?.id) {
+ createdBoardIds.push(clone.body.id);
+ }
+ }
+
+ expect(response.status).toBe(200);
+ expect(response.body.id).not.toBe(boardId);
+ expect(response.body).toMatchObject({
+ type: 'website',
+ name,
+ userId: seed.admin.id,
+ parameters: { websiteId: seed.website.id },
+ });
+ expect(defaults.status).toBe(200);
+ expect(defaults.body.id).not.toBe(boardId);
+ expect(defaults.body.name).toBe((await admin.get(`/api/boards/${boardId}`)).body.name);
+ expect(denied.status).toBe(401);
+ expect(unknown.status).toBe(401);
+ });
+
+ test('POST /api/boards/{boardId}/shares creates a share', async ({ admin, user, viewer }) => {
+ const name = uniqueName('share');
+ const response = await admin.post(`/api/boards/${boardId}/shares`, {
+ name,
+ parameters: { theme: 'dark' },
+ });
+ const missingName = await admin.post(`/api/boards/${boardId}/shares`, {});
+ const denied = await viewer.post(`/api/boards/${boardId}/shares`, { name });
+ const unknown = await user.post(`/api/boards/${UNKNOWN_UUID}/shares`, { name });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ id: expect.any(String),
+ entityId: boardId,
+ name,
+ shareType: 4,
+ slug: expect.any(String),
+ parameters: { theme: 'dark' },
+ });
+ expect(response.body.slug).toHaveLength(16);
+ expect(missingName.status).toBe(400);
+ expect(denied.status).toBe(401);
+ expect(unknown.status).toBe(401);
+
+ shareId = response.body.id;
+ });
+
+ test('GET /api/boards/{boardId}/shares lists the board shares', async ({ admin, viewer }) => {
+ const response = await admin.get(`/api/boards/${boardId}/shares`);
+ const paged = await admin.get(`/api/boards/${boardId}/shares`, {
+ params: { page: 2, pageSize: 1 },
+ });
+ const denied = await viewer.get(`/api/boards/${boardId}/shares`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ data: expect.any(Array), count: expect.any(Number) });
+ expect(response.body.data.map((s: any) => s.id)).toContain(shareId);
+ expect(response.body.data[0]).toMatchObject({
+ entityId: boardId,
+ shareType: 4,
+ slug: expect.any(String),
+ createdAt: expect.any(String),
+ });
+ expect(paged.status).toBe(200);
+ expect(paged.body).toMatchObject({ page: 2, pageSize: 1, count: 1, data: [] });
+ expect(denied.status).toBe(401);
+ });
+
+ test('DELETE /api/boards/{boardId} deletes a board', async ({ admin, user, viewer }) => {
+ const denied = await viewer.del(`/api/boards/${boardId}`);
+ const notOwner = await user.del(`/api/boards/${boardId}`);
+ const response = await admin.del(`/api/boards/${boardId}`);
+ const gone = await admin.get(`/api/boards/${boardId}`);
+ // Team members hold website:delete and can delete team boards.
+ const member = await user.del(`/api/boards/${teamBoardId}`);
+
+ expect(denied.status).toBe(401);
+ expect(notOwner.status).toBe(401);
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+ expect(gone.status).toBe(200);
+ expect(gone.body).toBeNull();
+ expect(member.status).toBe(200);
+ });
+});
+
+test.describe('Dashboard', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ test('POST /api/dashboard creates or updates the caller dashboard', async ({
+ user,
+ api,
+ seed,
+ }) => {
+ const name = uniqueName('dashboard');
+ const response = await user.post('/api/dashboard', {
+ name,
+ description: 'api suite dashboard',
+ parameters: { rows: [] },
+ });
+ const updated = await user.post('/api/dashboard', {
+ name: `${name}-2`,
+ description: 'api suite dashboard',
+ parameters: { rows: [{ id: 'row-1', columns: [] }] },
+ });
+ const invalid = await user.post('/api/dashboard', { name: 'x'.repeat(201) });
+ const anonymous = await api.post('/api/dashboard', { name, description: '' });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ id: seed.user.id,
+ type: 'dashboard',
+ userId: seed.user.id,
+ name,
+ parameters: { rows: [] },
+ });
+ expect(updated.status).toBe(200);
+ expect(updated.body).toMatchObject({
+ id: seed.user.id,
+ name: `${name}-2`,
+ parameters: { rows: [{ id: 'row-1', columns: [] }] },
+ });
+ expect(invalid.status).toBe(400);
+ expect(anonymous.status).toBe(401);
+ });
+
+ test('GET /api/dashboard returns the caller dashboard', async ({ user, api, seed }) => {
+ const response = await user.get('/api/dashboard');
+ const anonymous = await api.get('/api/dashboard');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ id: seed.user.id,
+ type: 'dashboard',
+ userId: seed.user.id,
+ parameters: { rows: [{ id: 'row-1', columns: [] }] },
+ });
+ expect(anonymous.status).toBe(401);
+ });
+});
diff --git a/tests/api/client.ts b/tests/api/client.ts
new file mode 100644
index 000000000..c6d756a43
--- /dev/null
+++ b/tests/api/client.ts
@@ -0,0 +1,95 @@
+import type { APIRequestContext } from '@playwright/test';
+import { recordApiCall } from './coverage/recorder';
+
+export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
+
+export interface ApiRequestOptions {
+ /** JSON-serialised unless a string is given (sent verbatim). */
+ data?: unknown;
+ params?: Record;
+ headers?: Record;
+}
+
+export interface ApiResponse {
+ status: number;
+ ok: boolean;
+ /** Parsed JSON body, or undefined when the body is empty / not JSON. */
+ body: T;
+ text: string;
+ headers: Record;
+}
+
+/**
+ * Thin wrapper over Playwright's APIRequestContext that carries default headers
+ * (auth) and records every call for the coverage reporter. All specs must go
+ * through this class — calls made with the raw `request` fixture are invisible
+ * to coverage.
+ */
+export class ApiClient {
+ constructor(
+ private readonly context: APIRequestContext,
+ private readonly headers: Record = {},
+ ) {}
+
+ with(headers: Record) {
+ return new ApiClient(this.context, { ...this.headers, ...headers });
+ }
+
+ bearer(token: string) {
+ return this.with({ authorization: `Bearer ${token}` });
+ }
+
+ async request(
+ method: HttpMethod,
+ path: string,
+ options: ApiRequestOptions = {},
+ ): Promise> {
+ const params = options.params
+ ? Object.fromEntries(
+ Object.entries(options.params).filter(([, value]) => value !== undefined),
+ )
+ : undefined;
+
+ const response = await this.context.fetch(path, {
+ method,
+ data: options.data,
+ params: params as Record,
+ headers: { ...this.headers, ...options.headers },
+ });
+
+ recordApiCall(method, path);
+
+ const text = await response.text();
+ let body: any;
+
+ try {
+ body = text ? JSON.parse(text) : undefined;
+ } catch {
+ body = undefined;
+ }
+
+ return {
+ status: response.status(),
+ ok: response.ok(),
+ body,
+ text,
+ headers: response.headers(),
+ };
+ }
+
+ get(path: string, options?: ApiRequestOptions) {
+ return this.request('GET', path, options);
+ }
+
+ post(path: string, data?: unknown, options?: ApiRequestOptions) {
+ return this.request('POST', path, { ...options, data });
+ }
+
+ put(path: string, data?: unknown, options?: ApiRequestOptions) {
+ return this.request('PUT', path, { ...options, data });
+ }
+
+ del(path: string, options?: ApiRequestOptions) {
+ return this.request('DELETE', path, options);
+ }
+}
diff --git a/tests/api/collection.spec.ts b/tests/api/collection.spec.ts
new file mode 100644
index 000000000..3e0321546
--- /dev/null
+++ b/tests/api/collection.spec.ts
@@ -0,0 +1,241 @@
+import { expect, test } from './fixtures';
+import { CACHE_HEADER, UNKNOWN_UUID } from './helpers/constants';
+import { HOSTNAME, PERSONAS } from './seed/dataset';
+
+const persona = PERSONAS[0];
+
+function pageview(website: string, extra: Record = {}) {
+ return {
+ type: 'event',
+ payload: {
+ website,
+ hostname: HOSTNAME,
+ language: persona.language,
+ screen: persona.screen,
+ userAgent: persona.userAgent,
+ // Private IP: the test image has no GeoLite database.
+ ip: '10.0.9.1',
+ url: '/collection-spec',
+ title: 'Collection spec',
+ ...extra,
+ },
+ };
+}
+
+test.describe('Collection', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ test('POST /api/send records a pageview and returns a cache token', async ({ api, seed }) => {
+ const response = await api.post('/api/send', pageview(seed.website.id));
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ cache: expect.any(String),
+ sessionId: expect.any(String),
+ visitId: expect.any(String),
+ });
+ });
+
+ test('POST /api/send continues the visit when the cache token is sent', async ({ api, seed }) => {
+ const first = await api.post('/api/send', pageview(seed.website.id));
+ const second = await api.post('/api/send', pageview(seed.website.id, { url: '/second' }), {
+ headers: { [CACHE_HEADER]: first.body.cache },
+ });
+
+ expect(second.status).toBe(200);
+ expect(second.body.sessionId).toBe(first.body.sessionId);
+ expect(second.body.visitId).toBe(first.body.visitId);
+ });
+
+ test('POST /api/send records custom, identify and performance payloads', async ({
+ api,
+ seed,
+ }) => {
+ const custom = await api.post(
+ '/api/send',
+ pageview(seed.website.id, { name: 'spec-event', data: { value: 1, label: 'spec' } }),
+ );
+ const identify = await api.post('/api/send', {
+ type: 'identify',
+ payload: { ...pageview(seed.website.id).payload, id: 'spec-user', data: { plan: 'spec' } },
+ });
+ const performance = await api.post('/api/send', {
+ type: 'performance',
+ payload: {
+ ...pageview(seed.website.id).payload,
+ lcp: 1500,
+ inp: 100,
+ cls: 0.1,
+ fcp: 800,
+ ttfb: 200,
+ },
+ });
+
+ expect(custom.status).toBe(200);
+ expect(identify.status).toBe(200);
+ expect(performance.status).toBe(200);
+ });
+
+ test('POST /api/send accepts link and pixel sources', async ({ api, seed }) => {
+ const link = await api.post('/api/send', {
+ type: 'event',
+ payload: { link: seed.link.id, url: '/', userAgent: persona.userAgent, ip: '10.0.9.2' },
+ });
+ const pixel = await api.post('/api/send', {
+ type: 'event',
+ payload: { pixel: seed.pixel.id, url: '/', userAgent: persona.userAgent, ip: '10.0.9.2' },
+ });
+
+ expect(link.status).toBe(200);
+ expect(pixel.status).toBe(200);
+ });
+
+ test('POST /api/send rejects an unknown website', async ({ api }) => {
+ const response = await api.post('/api/send', pageview(UNKNOWN_UUID));
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toMatchObject({ message: 'Website not found.', status: 400 });
+ });
+
+ test('POST /api/send requires exactly one of website, link or pixel', async ({ api, seed }) => {
+ const none = await api.post('/api/send', { type: 'event', payload: { url: '/' } });
+ const two = await api.post('/api/send', pageview(seed.website.id, { link: seed.link.id }));
+
+ expect(none.status).toBe(400);
+ expect(two.status).toBe(400);
+ expect(two.body.error.code).toBe('bad-request');
+ });
+
+ test('POST /api/send rejects invalid payloads', async ({ api, seed }) => {
+ const formula = await api.post('/api/send', pageview(seed.website.id, { name: '=cmd()' }));
+ const vitals = await api.post('/api/send', {
+ type: 'performance',
+ payload: { ...pageview(seed.website.id).payload, lcp: -1 },
+ });
+ const type = await api.post('/api/send', {
+ type: 'bogus',
+ payload: { website: seed.website.id },
+ });
+
+ expect(formula.status).toBe(400);
+ expect(vitals.status).toBe(400);
+ expect(type.status).toBe(400);
+ });
+
+ test('POST /api/batch processes each payload and reports per-item errors', async ({
+ api,
+ seed,
+ }) => {
+ const response = await api.post('/api/batch', [
+ pageview(seed.website.id, { url: '/batch-1' }),
+ pageview(UNKNOWN_UUID, { url: '/batch-2' }),
+ pageview(seed.website.id, { url: '/batch-3', name: 'batch-event' }),
+ ]);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ size: 3,
+ processed: 2,
+ errors: 1,
+ cache: expect.any(String),
+ });
+ expect(response.body.details).toHaveLength(1);
+ expect(response.body.details[0].index).toBe(1);
+ });
+
+ test('POST /api/batch validates the body', async ({ api, seed }) => {
+ const object = await api.post('/api/batch', pageview(seed.website.id));
+ const tooMany = await api.post(
+ '/api/batch',
+ Array.from({ length: 501 }, () => ({})),
+ );
+
+ expect(object.status).toBe(400);
+ expect(tooMany.status).toBe(400);
+ });
+
+ test('POST /api/record stores replay and heatmap events for a recorded visit', async ({
+ api,
+ seed,
+ }) => {
+ const visit = await api.post('/api/send', pageview(seed.website.id));
+ const headers = { [CACHE_HEADER]: visit.body.cache };
+ const now = Date.now();
+
+ const replay = await api.post(
+ '/api/record',
+ {
+ type: 'record',
+ payload: {
+ website: seed.website.id,
+ events: [
+ {
+ type: 4,
+ data: { href: `https://${HOSTNAME}/`, width: 1280, height: 800 },
+ timestamp: now,
+ },
+ { type: 2, data: { node: { type: 0, childNodes: [] } }, timestamp: now + 10 },
+ ],
+ },
+ },
+ { headers },
+ );
+ const heatmap = await api.post(
+ '/api/record',
+ {
+ type: 'heatmap',
+ payload: {
+ website: seed.website.id,
+ events: [
+ { type: 'click', url: `https://${HOSTNAME}/`, x: 1, y: 2, timestamp: now },
+ { type: 'scroll', url: `https://${HOSTNAME}/`, scrollPct: 50, timestamp: now },
+ ],
+ },
+ },
+ { headers },
+ );
+
+ expect(replay.status).toBe(200);
+ expect(replay.body).toEqual({ ok: true });
+ expect(heatmap.status).toBe(200);
+ expect(heatmap.body).toEqual({ ok: true });
+ });
+
+ test('POST /api/record requires a session token', async ({ api, seed }) => {
+ const response = await api.post('/api/record', {
+ type: 'record',
+ payload: { website: seed.website.id, events: [{ type: 2 }] },
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error.message).toBe('Missing session token.');
+ });
+
+ test('POST /api/record reports when the recorder is disabled', async ({ api, seed }) => {
+ const visit = await api.post('/api/send', pageview(seed.website2.id));
+ const response = await api.post(
+ '/api/record',
+ { type: 'record', payload: { website: seed.website2.id, events: [{ type: 2 }] } },
+ { headers: { [CACHE_HEADER]: visit.body.cache } },
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: false, reason: 'recorder_disabled' });
+ });
+
+ test('POST /api/record rejects invalid and oversized payloads', async ({ api, seed }) => {
+ const invalid = await api.post('/api/record', { type: 'bogus', payload: {} });
+ const oversized = await api.post(
+ '/api/record',
+ JSON.stringify({
+ type: 'record',
+ payload: { website: seed.website.id, events: [{ type: 2, data: 'x'.repeat(1_000_100) }] },
+ }),
+ { headers: { 'content-type': 'application/json' } },
+ );
+
+ expect(invalid.status).toBe(400);
+ expect(oversized.status).toBe(413);
+ expect(oversized.body.error.code).toBe('payload-too-large');
+ });
+});
diff --git a/tests/api/coverage/allowlist.ts b/tests/api/coverage/allowlist.ts
new file mode 100644
index 000000000..922da4fd2
--- /dev/null
+++ b/tests/api/coverage/allowlist.ts
@@ -0,0 +1,30 @@
+export interface AllowlistEntry {
+ /** Operation key, e.g. "POST /api/auth/sso" */
+ key: string;
+ /** Why this operation cannot be exercised by the harness. */
+ reason: string;
+ /** Only applies when running against this backend (UMAMI_TEST_DB); omit for both. */
+ db?: 'postgres' | 'clickhouse';
+}
+
+/**
+ * Operations that are intentionally not covered. Keep this list short and every
+ * entry justified — the reporter warns when an allowlisted operation is
+ * actually called, so entries can be removed once they gain a test.
+ */
+export const allowlist: AllowlistEntry[] = [
+ {
+ key: 'POST /api/auth/sso',
+ reason:
+ 'Cloud single sign-on handoff. Requires REDIS_URL (returns 500 "Redis is disabled" otherwise); Redis is out of scope for the harness.',
+ },
+ {
+ key: 'GET /api/auth/subscription',
+ reason: 'Cloud billing/subscription lookup; only meaningful under CLOUD_MODE.',
+ },
+ {
+ key: 'DELETE /api/websites/{websiteId}/sessions/{sessionId}',
+ reason: 'Session deletion needs relational storage; sessions.spec.ts skips it on ClickHouse.',
+ db: 'clickhouse',
+ },
+];
diff --git a/tests/api/coverage/matcher.test.ts b/tests/api/coverage/matcher.test.ts
new file mode 100644
index 000000000..f86d74e51
--- /dev/null
+++ b/tests/api/coverage/matcher.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, test } from 'vitest';
+import { createMatcher, getPathname } from './matcher';
+
+const templates = [
+ '/api/websites',
+ '/api/websites/charts',
+ '/api/websites/{websiteId}',
+ '/api/websites/{websiteId}/replays',
+ '/api/websites/{websiteId}/replays/saved',
+ '/api/websites/{websiteId}/replays/{replayId}',
+ '/api/websites/{websiteId}/replays/saved/{replayId}',
+ '/api/websites/{websiteId}/sessions/stats',
+ '/api/websites/{websiteId}/sessions/weekly',
+ '/api/websites/{websiteId}/sessions/{sessionId}',
+ '/api/reports',
+ '/api/reports/funnel',
+ '/api/reports/{reportId}',
+ '/api/teams/join',
+ '/api/teams/{teamId}',
+ '/api/share/{slug}',
+ '/api/share/id/{shareId}',
+];
+
+const match = createMatcher(templates);
+
+describe('getPathname', () => {
+ test('strips origin, query and hash', () => {
+ expect(getPathname('http://localhost:3100/api/websites?page=1#x')).toBe('/api/websites');
+ expect(getPathname('/api/websites?page=1')).toBe('/api/websites');
+ });
+
+ test('strips trailing slashes but keeps root', () => {
+ expect(getPathname('/api/websites/')).toBe('/api/websites');
+ expect(getPathname('/')).toBe('/');
+ });
+});
+
+describe('createMatcher', () => {
+ test('prefers literal segments over parameters', () => {
+ expect(match('/api/websites/charts')).toBe('/api/websites/charts');
+ expect(match('/api/websites/abc-123')).toBe('/api/websites/{websiteId}');
+ expect(match('/api/reports/funnel')).toBe('/api/reports/funnel');
+ expect(match('/api/reports/abc-123')).toBe('/api/reports/{reportId}');
+ expect(match('/api/teams/join')).toBe('/api/teams/join');
+ expect(match('/api/teams/abc-123')).toBe('/api/teams/{teamId}');
+ });
+
+ test('resolves nested literal/parameter ambiguity', () => {
+ expect(match('/api/websites/w1/replays/saved')).toBe('/api/websites/{websiteId}/replays/saved');
+ expect(match('/api/websites/w1/replays/r1')).toBe(
+ '/api/websites/{websiteId}/replays/{replayId}',
+ );
+ expect(match('/api/websites/w1/replays/saved/r1')).toBe(
+ '/api/websites/{websiteId}/replays/saved/{replayId}',
+ );
+ expect(match('/api/websites/w1/sessions/stats')).toBe(
+ '/api/websites/{websiteId}/sessions/stats',
+ );
+ expect(match('/api/websites/w1/sessions/weekly')).toBe(
+ '/api/websites/{websiteId}/sessions/weekly',
+ );
+ expect(match('/api/websites/w1/sessions/s1')).toBe(
+ '/api/websites/{websiteId}/sessions/{sessionId}',
+ );
+ });
+
+ test('distinguishes templates by segment count', () => {
+ expect(match('/api/share/abcdef')).toBe('/api/share/{slug}');
+ expect(match('/api/share/id/abcdef')).toBe('/api/share/id/{shareId}');
+ });
+
+ test('ignores query strings and absolute URLs', () => {
+ expect(match('http://localhost:3100/api/websites/w1?startAt=1&endAt=2')).toBe(
+ '/api/websites/{websiteId}',
+ );
+ });
+
+ test('returns null for unknown paths', () => {
+ expect(match('/api/nope')).toBeNull();
+ expect(match('/api/websites/w1/unknown')).toBeNull();
+ });
+});
diff --git a/tests/api/coverage/matcher.ts b/tests/api/coverage/matcher.ts
new file mode 100644
index 000000000..653c75c82
--- /dev/null
+++ b/tests/api/coverage/matcher.ts
@@ -0,0 +1,57 @@
+export type PathMatcher = (url: string) => string | null;
+
+/** Strips origin, query string, hash and trailing slashes from a URL or path. */
+export function getPathname(url: string): string {
+ const raw = /^https?:\/\//i.test(url) ? new URL(url).pathname : url.split(/[?#]/)[0];
+
+ return raw.length > 1 ? raw.replace(/\/+$/, '') : raw;
+}
+
+/**
+ * Maps a concrete request path back to an OpenAPI path template.
+ *
+ * Candidates must have the same number of segments. A literal segment match
+ * scores higher than a `{param}` match, so `/api/websites/charts` resolves to
+ * `/api/websites/charts` rather than `/api/websites/{websiteId}`, and
+ * `/api/reports/funnel` beats `/api/reports/{reportId}`.
+ */
+export function createMatcher(templates: string[]): PathMatcher {
+ const candidates = [...new Set(templates)].map(template => ({
+ template,
+ segments: template.split('/').filter(Boolean),
+ }));
+
+ return url => {
+ const segments = getPathname(url).split('/').filter(Boolean);
+ let best: { template: string; score: number } | null = null;
+
+ for (const candidate of candidates) {
+ if (candidate.segments.length !== segments.length) {
+ continue;
+ }
+
+ let score = 0;
+ let matches = true;
+
+ for (let i = 0; i < segments.length; i++) {
+ const expected = candidate.segments[i];
+ const actual = segments[i];
+
+ if (expected === actual) {
+ score += 1;
+ } else if (expected.startsWith('{') && expected.endsWith('}') && actual) {
+ // parameter segment: matches anything non-empty
+ } else {
+ matches = false;
+ break;
+ }
+ }
+
+ if (matches && (!best || score > best.score)) {
+ best = { template: candidate.template, score };
+ }
+ }
+
+ return best?.template ?? null;
+ };
+}
diff --git a/tests/api/coverage/oracle.ts b/tests/api/coverage/oracle.ts
new file mode 100644
index 000000000..4cc9d8527
--- /dev/null
+++ b/tests/api/coverage/oracle.ts
@@ -0,0 +1,57 @@
+import { readFileSync } from 'node:fs';
+import { OPENAPI_FILE } from '../paths';
+
+/**
+ * The coverage oracle is the generated OpenAPI document. `pnpm build` cannot
+ * succeed with an undocumented route (see src/openapi/coverage.ts), so every
+ * operation in public/openapi.json is exactly the set of endpoints that exist.
+ */
+export interface ApiOperation {
+ /** Same format as src/openapi/operation.ts getOperationKey: "GET /api/websites" */
+ key: string;
+ method: string;
+ /** OpenAPI path template, e.g. "/api/websites/{websiteId}" */
+ path: string;
+ /** Route file that implements the operation (x-umami-source) */
+ source: string;
+ tag: string;
+}
+
+const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
+
+let cached: ApiOperation[] | undefined;
+
+export function loadOperations(): ApiOperation[] {
+ if (cached) {
+ return cached;
+ }
+
+ const document = JSON.parse(readFileSync(OPENAPI_FILE, 'utf8'));
+ const operations: ApiOperation[] = [];
+
+ for (const [pathTemplate, pathItem] of Object.entries>(
+ document.paths ?? {},
+ )) {
+ for (const method of METHODS) {
+ const operation = pathItem?.[method];
+
+ if (!operation) {
+ continue;
+ }
+
+ operations.push({
+ key: `${method.toUpperCase()} ${pathTemplate}`,
+ method: method.toUpperCase(),
+ path: pathTemplate,
+ source: operation['x-umami-source'] ?? '',
+ tag: operation.tags?.[0] ?? 'Other',
+ });
+ }
+ }
+
+ cached = operations.sort(
+ (a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method),
+ );
+
+ return cached;
+}
diff --git a/tests/api/coverage/recorder.ts b/tests/api/coverage/recorder.ts
new file mode 100644
index 000000000..f77c39f3f
--- /dev/null
+++ b/tests/api/coverage/recorder.ts
@@ -0,0 +1,61 @@
+import { appendFileSync, mkdirSync } from 'node:fs';
+import path from 'node:path';
+import { COVERAGE_DIR } from '../paths';
+import { createMatcher, getPathname, type PathMatcher } from './matcher';
+import { loadOperations } from './oracle';
+
+/**
+ * Records every API call made through ApiClient as "METHOD /path/{template}".
+ *
+ * Playwright workers are separate processes, so each process appends to its
+ * own log file; the reporter merges them at the end of the run.
+ */
+export const UNMATCHED_PREFIX = 'UNMATCHED ';
+
+let enabled = true;
+let matcher: PathMatcher | undefined;
+let logFile: string | undefined;
+const seen = new Set();
+
+/**
+ * Global setup calls this so the seeding traffic does not count as coverage —
+ * only calls made from specs should satisfy the coverage check.
+ */
+export function disableCoverageRecording() {
+ enabled = false;
+}
+
+function getMatcher() {
+ if (!matcher) {
+ matcher = createMatcher(loadOperations().map(operation => operation.path));
+ }
+
+ return matcher;
+}
+
+function getLogFile() {
+ if (!logFile) {
+ mkdirSync(COVERAGE_DIR, { recursive: true });
+ logFile = path.join(COVERAGE_DIR, `worker-${process.pid}.log`);
+ }
+
+ return logFile;
+}
+
+export function recordApiCall(method: string, url: string) {
+ if (!enabled) {
+ return;
+ }
+
+ const template = getMatcher()(url);
+ const line = template
+ ? `${method.toUpperCase()} ${template}`
+ : `${UNMATCHED_PREFIX}${method.toUpperCase()} ${getPathname(url)}`;
+
+ if (seen.has(line)) {
+ return;
+ }
+
+ seen.add(line);
+ appendFileSync(getLogFile(), `${line}\n`);
+}
diff --git a/tests/api/coverage/report.ts b/tests/api/coverage/report.ts
new file mode 100644
index 000000000..5ac90960d
--- /dev/null
+++ b/tests/api/coverage/report.ts
@@ -0,0 +1,138 @@
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
+import path from 'node:path';
+import { COVERAGE_DIR } from '../paths';
+import { allowlist } from './allowlist';
+import { type ApiOperation, loadOperations } from './oracle';
+import { UNMATCHED_PREFIX } from './recorder';
+
+export interface CoverageSummary {
+ total: number;
+ covered: string[];
+ allowlisted: string[];
+ uncovered: ApiOperation[];
+ /** Allowlisted operations that were actually called — the entry can be removed. */
+ staleAllowlist: string[];
+ /** Allowlist keys that don't exist in the OpenAPI document. */
+ unknownAllowlist: string[];
+ /** Calls whose path could not be mapped to any documented operation. */
+ unmatched: string[];
+}
+
+export function readRecordedCalls() {
+ const covered = new Set();
+ const unmatched = new Set();
+
+ if (!existsSync(COVERAGE_DIR)) {
+ return { covered, unmatched };
+ }
+
+ for (const file of readdirSync(COVERAGE_DIR)) {
+ if (!file.endsWith('.log')) {
+ continue;
+ }
+
+ for (const line of readFileSync(path.join(COVERAGE_DIR, file), 'utf8').split('\n')) {
+ const entry = line.trim();
+
+ if (!entry) {
+ continue;
+ }
+
+ if (entry.startsWith(UNMATCHED_PREFIX)) {
+ unmatched.add(entry.slice(UNMATCHED_PREFIX.length));
+ } else {
+ covered.add(entry);
+ }
+ }
+ }
+
+ return { covered, unmatched };
+}
+
+export function computeCoverage(): CoverageSummary {
+ const operations = loadOperations();
+ const { covered, unmatched } = readRecordedCalls();
+ const known = new Set(operations.map(operation => operation.key));
+ const db = process.env.UMAMI_TEST_DB === 'clickhouse' ? 'clickhouse' : 'postgres';
+ const allowed = new Set(
+ allowlist.filter(entry => !entry.db || entry.db === db).map(entry => entry.key),
+ );
+
+ return {
+ total: operations.length,
+ covered: operations.filter(op => covered.has(op.key)).map(op => op.key),
+ allowlisted: [...allowed].filter(key => known.has(key) && !covered.has(key)),
+ uncovered: operations.filter(op => !covered.has(op.key) && !allowed.has(op.key)),
+ staleAllowlist: [...allowed].filter(key => covered.has(key)),
+ unknownAllowlist: [...allowed].filter(key => !known.has(key)),
+ unmatched: [...unmatched].sort(),
+ };
+}
+
+function groupByTag(operations: ApiOperation[]) {
+ const groups = new Map();
+
+ for (const operation of operations) {
+ const group = groups.get(operation.tag) ?? [];
+ group.push(operation);
+ groups.set(operation.tag, group);
+ }
+
+ return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
+}
+
+export function formatCoverage(summary: CoverageSummary): string {
+ const lines: string[] = [];
+ const { total, covered, allowlisted, uncovered } = summary;
+
+ lines.push('');
+ lines.push(
+ `API coverage: ${covered.length + allowlisted.length}/${total} operations ` +
+ `(${covered.length} covered, ${allowlisted.length} allowlisted, ${uncovered.length} uncovered)`,
+ );
+
+ if (uncovered.length) {
+ lines.push('');
+ lines.push('Uncovered operations:');
+
+ for (const [tag, operations] of groupByTag(uncovered)) {
+ lines.push(` ${tag}`);
+
+ for (const operation of operations) {
+ const source = operation.source ? ` <- ${operation.source}` : '';
+ lines.push(` ${operation.key}${source}`);
+ }
+ }
+ }
+
+ if (summary.staleAllowlist.length) {
+ lines.push('');
+ lines.push('Allowlisted operations that were called (remove them from coverage/allowlist.ts):');
+
+ for (const key of summary.staleAllowlist) {
+ lines.push(` ${key}`);
+ }
+ }
+
+ if (summary.unknownAllowlist.length) {
+ lines.push('');
+ lines.push('Allowlist entries that do not exist in public/openapi.json:');
+
+ for (const key of summary.unknownAllowlist) {
+ lines.push(` ${key}`);
+ }
+ }
+
+ if (summary.unmatched.length) {
+ lines.push('');
+ lines.push('Calls that did not match any documented operation:');
+
+ for (const entry of summary.unmatched) {
+ lines.push(` ${entry}`);
+ }
+ }
+
+ lines.push('');
+
+ return lines.join('\n');
+}
diff --git a/tests/api/coverage/reporter.ts b/tests/api/coverage/reporter.ts
new file mode 100644
index 000000000..0824fbbc5
--- /dev/null
+++ b/tests/api/coverage/reporter.ts
@@ -0,0 +1,51 @@
+import { mkdirSync, writeFileSync } from 'node:fs';
+import path from 'node:path';
+import type { FullResult, Reporter } from '@playwright/test/reporter';
+import { COVERAGE_DIR } from '../paths';
+import { computeCoverage, formatCoverage } from './report';
+
+/**
+ * Enforces endpoint coverage at the end of the run.
+ *
+ * API_COVERAGE=enforce (default) fail a passing run if any operation is uncovered
+ * API_COVERAGE=report print the summary only
+ * API_COVERAGE=off skip entirely
+ */
+class ApiCoverageReporter implements Reporter {
+ printsToStdio() {
+ return true;
+ }
+
+ async onEnd(result: FullResult) {
+ const mode = process.env.API_COVERAGE ?? 'enforce';
+
+ if (mode === 'off') {
+ return;
+ }
+
+ const summary = computeCoverage();
+
+ mkdirSync(COVERAGE_DIR, { recursive: true });
+ writeFileSync(path.join(COVERAGE_DIR, 'summary.json'), JSON.stringify(summary, null, 2));
+
+ console.log(formatCoverage(summary));
+
+ if (mode !== 'enforce' || summary.uncovered.length === 0) {
+ return;
+ }
+
+ if (result.status !== 'passed') {
+ console.log('API coverage not enforced because the run did not pass.\n');
+ return;
+ }
+
+ console.log(
+ `API coverage check failed: ${summary.uncovered.length} operation(s) were never called. ` +
+ 'Add a spec that exercises them or an entry in tests/api/coverage/allowlist.ts.\n',
+ );
+
+ return { status: 'failed' as const };
+ }
+}
+
+export default ApiCoverageReporter;
diff --git a/tests/api/docker/clickhouse-init/00-create-db.sql b/tests/api/docker/clickhouse-init/00-create-db.sql
new file mode 100644
index 000000000..db363a58d
--- /dev/null
+++ b/tests/api/docker/clickhouse-init/00-create-db.sql
@@ -0,0 +1,3 @@
+-- CLICKHOUSE_DB already creates this before init scripts run; kept as a guard so
+-- db/clickhouse/schema.sql (which hardcodes the `umami.` prefix) never fails.
+CREATE DATABASE IF NOT EXISTS umami;
diff --git a/tests/api/event-data.spec.ts b/tests/api/event-data.spec.ts
new file mode 100644
index 000000000..552b6d7c8
--- /dev/null
+++ b/tests/api/event-data.spec.ts
@@ -0,0 +1,565 @@
+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>((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);
+ });
+});
diff --git a/tests/api/fixtures.ts b/tests/api/fixtures.ts
new file mode 100644
index 000000000..d07a29a1f
--- /dev/null
+++ b/tests/api/fixtures.ts
@@ -0,0 +1,91 @@
+import { test as base, request } from '@playwright/test';
+import { ApiClient } from './client';
+import { login } from './helpers/auth';
+import { SHARE_CONTEXT_HEADER, SHARE_TOKEN_HEADER } from './helpers/constants';
+import { readSeedState, type SeedState } from './seed/state';
+
+export interface WorkerTokens {
+ admin: string;
+ user: string;
+ viewer: string;
+ apiKey: { id: string; key: string };
+}
+
+interface WorkerFixtures {
+ /** Seeded entities and dataset metadata written by global setup. */
+ seed: SeedState;
+ /** Auth tokens created once per worker process. */
+ tokens: WorkerTokens;
+}
+
+interface TestFixtures {
+ /** Unauthenticated client. */
+ api: ApiClient;
+ /** Logged in as the built-in admin (owns the seeded website, link, pixel, team). */
+ admin: ApiClient;
+ /** Logged in as the seeded regular user (owns website2, member of the seeded team). */
+ user: ApiClient;
+ /** Logged in as the seeded view-only user, who owns nothing. */
+ viewer: ApiClient;
+ /** Authenticated with an admin API key (`umami_…`) instead of a session token. */
+ apiKey: ApiClient;
+ /** Resolves a share slug to a client authenticated with a share token. */
+ share: (slug?: string) => Promise;
+}
+
+export const test = base.extend({
+ // biome-ignore lint/correctness/noEmptyPattern: Playwright requires the destructuring form
+ seed: [async ({}, use) => use(readSeedState()), { scope: 'worker' }],
+
+ tokens: [
+ async ({ seed }, use, workerInfo) => {
+ const context = await request.newContext({ baseURL: workerInfo.project.use.baseURL });
+ const raw = new ApiClient(context);
+
+ try {
+ const admin = await login(raw, seed.admin);
+ const user = await login(raw, seed.user);
+ const viewer = await login(raw, seed.viewer);
+
+ const created = await raw
+ .bearer(admin)
+ .post('/api/me/api-keys', { name: `api-tests-worker-${workerInfo.workerIndex}` });
+
+ if (created.status !== 200 || !created.body?.key) {
+ throw new Error(`Failed to create API key (${created.status}): ${created.text}`);
+ }
+
+ const apiKey = { id: created.body.id as string, key: created.body.key as string };
+
+ await use({ admin, user, viewer, apiKey });
+
+ await raw.bearer(admin).del(`/api/me/api-keys/${apiKey.id}`);
+ } finally {
+ await context.dispose();
+ }
+ },
+ { scope: 'worker' },
+ ],
+
+ api: async ({ request }, use) => use(new ApiClient(request)),
+ admin: async ({ api, tokens }, use) => use(api.bearer(tokens.admin)),
+ user: async ({ api, tokens }, use) => use(api.bearer(tokens.user)),
+ viewer: async ({ api, tokens }, use) => use(api.bearer(tokens.viewer)),
+ apiKey: async ({ api, tokens }, use) => use(api.bearer(tokens.apiKey.key)),
+
+ share: async ({ api, seed }, use) =>
+ use(async (slug = seed.share.slug) => {
+ const response = await api.get(`/api/share/${slug}`);
+
+ if (response.status !== 200 || !response.body?.token) {
+ throw new Error(`Failed to resolve share ${slug} (${response.status}): ${response.text}`);
+ }
+
+ return api.with({
+ [SHARE_TOKEN_HEADER]: response.body.token,
+ [SHARE_CONTEXT_HEADER]: '1',
+ });
+ }),
+});
+
+export { expect } from '@playwright/test';
diff --git a/tests/api/global-setup.ts b/tests/api/global-setup.ts
new file mode 100644
index 000000000..c02ed29f5
--- /dev/null
+++ b/tests/api/global-setup.ts
@@ -0,0 +1,82 @@
+/* eslint-disable no-console */
+import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
+import { type FullConfig, request } from '@playwright/test';
+import { ApiClient } from './client';
+import { disableCoverageRecording } from './coverage/recorder';
+import { assertDisposableTarget, COVERAGE_DIR, SEED_FILE } from './paths';
+import { seedEnvironment } from './seed/setup';
+
+const HEARTBEAT_TIMEOUT_MS = 120_000;
+
+async function waitForHeartbeat(baseURL: string, timeoutMs: number) {
+ const deadline = Date.now() + timeoutMs;
+ let lastError = '';
+
+ while (Date.now() < deadline) {
+ try {
+ const response = await fetch(`${baseURL}/api/heartbeat`);
+
+ if (response.ok) {
+ return;
+ }
+
+ lastError = `HTTP ${response.status}`;
+ } catch (error) {
+ lastError = (error as Error).message;
+ }
+
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ }
+
+ throw new Error(
+ `Timed out waiting for ${baseURL}/api/heartbeat (${lastError}). ` +
+ 'Start the stack with `pnpm test:api:up` or run the full flow with `pnpm test:api`.',
+ );
+}
+
+export default async function globalSetup(config: FullConfig) {
+ // Seeding traffic must not count towards endpoint coverage.
+ disableCoverageRecording();
+
+ const baseURL = config.projects[0]?.use?.baseURL;
+
+ if (!baseURL) {
+ throw new Error('playwright.api.config.ts must define use.baseURL');
+ }
+
+ assertDisposableTarget(baseURL);
+
+ // API_SKIP_SEED=1 reuses the seed from a previous run against a kept stack
+ // (fast iteration on a single spec).
+ const skipSeed = !!process.env.API_SKIP_SEED && existsSync(SEED_FILE);
+
+ // Coverage must reflect this run only: the per-process logs are append-only and
+ // the reporter merges every file in the directory, so stale logs from an earlier
+ // run would let removed endpoint calls still count as covered.
+ rmSync(COVERAGE_DIR, { recursive: true, force: true });
+ mkdirSync(COVERAGE_DIR, { recursive: true });
+
+ console.log(`Waiting for ${baseURL}/api/heartbeat ...`);
+ await waitForHeartbeat(baseURL, HEARTBEAT_TIMEOUT_MS);
+
+ if (skipSeed) {
+ console.log(`Reusing seed state from ${SEED_FILE} (API_SKIP_SEED is set).`);
+ return;
+ }
+
+ const context = await request.newContext({ baseURL });
+
+ try {
+ const started = Date.now();
+ const state = await seedEnvironment(new ApiClient(context));
+
+ writeFileSync(SEED_FILE, JSON.stringify(state, null, 2));
+
+ console.log(
+ `Seeded ${state.db} environment in ${((Date.now() - started) / 1000).toFixed(1)}s ` +
+ `(${state.data.expectedPageviews} pageviews on ${state.website.domain})`,
+ );
+ } finally {
+ await context.dispose();
+ }
+}
diff --git a/tests/api/helpers/auth.ts b/tests/api/helpers/auth.ts
new file mode 100644
index 000000000..ce09dc699
--- /dev/null
+++ b/tests/api/helpers/auth.ts
@@ -0,0 +1,16 @@
+import type { ApiClient } from '../client';
+
+export interface Credentials {
+ username: string;
+ password: string;
+}
+
+export async function login(api: ApiClient, { username, password }: Credentials) {
+ const response = await api.post('/api/auth/login', { username, password });
+
+ if (response.status !== 200 || !response.body?.token) {
+ throw new Error(`Login failed for ${username} (${response.status}): ${response.text}`);
+ }
+
+ return response.body.token as string;
+}
diff --git a/tests/api/helpers/constants.ts b/tests/api/helpers/constants.ts
new file mode 100644
index 000000000..a4e2970cb
--- /dev/null
+++ b/tests/api/helpers/constants.ts
@@ -0,0 +1,45 @@
+/**
+ * Constants mirrored from src/lib/constants.ts. The API suite deliberately does
+ * not import from src/ (server-only modules, path aliases), so keep these in
+ * sync by hand — they change rarely.
+ */
+
+/** Created by prisma/migrations/01_init on first boot. */
+export const ADMIN_USER = {
+ id: '41e2b680-648e-4b09-bcd7-3e2b10c06264',
+ username: 'admin',
+ password: 'umami',
+ role: 'admin',
+} as const;
+
+export const ROLES = {
+ admin: 'admin',
+ user: 'user',
+ viewOnly: 'view-only',
+ teamOwner: 'team-owner',
+ teamManager: 'team-manager',
+ teamMember: 'team-member',
+ teamViewOnly: 'team-view-only',
+} as const;
+
+export const EVENT_TYPE = {
+ pageView: 1,
+ customEvent: 2,
+ linkEvent: 3,
+ pixelEvent: 4,
+ performance: 5,
+} as const;
+
+export const ENTITY_TYPE = {
+ website: 1,
+ link: 2,
+ pixel: 3,
+ board: 4,
+} as const;
+
+export const SHARE_TOKEN_HEADER = 'x-umami-share-token';
+export const SHARE_CONTEXT_HEADER = 'x-umami-share-context';
+export const CACHE_HEADER = 'x-umami-cache';
+
+/** A syntactically valid v4 UUID that never exists in the database. */
+export const UNKNOWN_UUID = '00000000-0000-4000-8000-000000000000';
diff --git a/tests/api/helpers/dates.ts b/tests/api/helpers/dates.ts
new file mode 100644
index 000000000..af4438be1
--- /dev/null
+++ b/tests/api/helpers/dates.ts
@@ -0,0 +1,16 @@
+import type { SeedState } from '../seed/state';
+
+export const TIMEZONE = 'UTC';
+
+/** `startAt`/`endAt` (ms) query params spanning the whole seeded dataset. */
+export function dateRange(seed: SeedState, extra: Record = {}) {
+ return { startAt: seed.range.startAt, endAt: Date.now(), ...extra };
+}
+
+/** `startDate`/`endDate` ISO strings used by report parameters. */
+export function dateRangeIso(seed: SeedState) {
+ return {
+ startDate: new Date(seed.range.startAt).toISOString(),
+ endDate: new Date().toISOString(),
+ };
+}
diff --git a/tests/api/helpers/entities.ts b/tests/api/helpers/entities.ts
new file mode 100644
index 000000000..d8c831b08
--- /dev/null
+++ b/tests/api/helpers/entities.ts
@@ -0,0 +1,102 @@
+import { randomUUID } from 'node:crypto';
+import type { ApiClient, ApiResponse } from '../client';
+
+/**
+ * Helpers for creating throwaway entities. Every spec should create the
+ * mutable resources it needs (with unique names) and delete them in
+ * `afterAll`; the seeded entities are shared and must stay read-only.
+ */
+
+export function uniqueName(prefix: string) {
+ return `${prefix}-${randomUUID().slice(0, 8)}`;
+}
+
+/** Link/pixel slugs must be at least 8 characters. */
+export function uniqueSlug(prefix: string) {
+ return `${prefix}${randomUUID().replace(/-/g, '').slice(0, 12)}`;
+}
+
+export function uniqueDomain() {
+ return `${uniqueName('site')}.test`;
+}
+
+export function assertStatus(response: ApiResponse, expected: number, label: string) {
+ if (response.status !== expected) {
+ throw new Error(
+ `${label}: expected HTTP ${expected}, got ${response.status}: ${response.text}`,
+ );
+ }
+
+ return response;
+}
+
+export async function createWebsite(client: ApiClient, overrides: Record = {}) {
+ const response = await client.post('/api/websites', {
+ name: uniqueName('website'),
+ domain: uniqueDomain(),
+ ...overrides,
+ });
+
+ return assertStatus(response, 200, 'create website').body;
+}
+
+export async function deleteWebsite(client: ApiClient, websiteId: string) {
+ await client.del(`/api/websites/${websiteId}`);
+}
+
+export async function createUser(admin: ApiClient, overrides: Record = {}) {
+ const password = 'throwaway-password';
+ const response = await admin.post('/api/users', {
+ username: uniqueName('user'),
+ password,
+ role: 'user',
+ ...overrides,
+ });
+
+ return { ...assertStatus(response, 200, 'create user').body, password };
+}
+
+export async function deleteUser(admin: ApiClient, userId: string) {
+ await admin.del(`/api/users/${userId}`);
+}
+
+/** POST /api/teams responds with `[team, teamUser]`. */
+export async function createTeam(client: ApiClient, overrides: Record = {}) {
+ const response = await client.post('/api/teams', { name: uniqueName('team'), ...overrides });
+ const [team, teamUser] = assertStatus(response, 200, 'create team').body;
+
+ return { ...team, teamUser };
+}
+
+export async function deleteTeam(client: ApiClient, teamId: string) {
+ await client.del(`/api/teams/${teamId}`);
+}
+
+export async function createLink(client: ApiClient, overrides: Record = {}) {
+ const response = await client.post('/api/links', {
+ name: uniqueName('link'),
+ url: 'https://umami.is/',
+ slug: uniqueSlug('link'),
+ ...overrides,
+ });
+
+ return assertStatus(response, 200, 'create link').body;
+}
+
+export async function deleteLink(client: ApiClient, linkId: string) {
+ await client.del(`/api/links/${linkId}`);
+}
+
+export async function createPixel(client: ApiClient, overrides: Record = {}) {
+ const response = await client.post('/api/pixels', {
+ name: uniqueName('pixel'),
+ slug: uniqueSlug('pixel'),
+ ...overrides,
+ });
+
+ return assertStatus(response, 200, 'create pixel').body;
+}
+
+export async function deletePixel(client: ApiClient, pixelId: string) {
+ await client.del(`/api/pixels/${pixelId}`);
+}
diff --git a/tests/api/helpers/totp.ts b/tests/api/helpers/totp.ts
new file mode 100644
index 000000000..914473a87
--- /dev/null
+++ b/tests/api/helpers/totp.ts
@@ -0,0 +1,47 @@
+import { generateSync } from 'otplib';
+
+/** TOTP defaults used by the server (otplib v13): SHA-1, 6 digits, 30 s step, no drift window. */
+const STEP_SECONDS = 30;
+
+/** Codes generated this close to the end of a step may expire before the server verifies them. */
+const SAFETY_MARGIN_SECONDS = 4;
+
+function sleep(ms: number) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+function secondsUntilNextStep() {
+ return STEP_SECONDS - (Math.floor(Date.now() / 1000) % STEP_SECONDS);
+}
+
+/**
+ * Returns a TOTP code for `secret` (the Base32 `manualKey` from setup/initiate)
+ * that is safe to submit immediately. Every accepted code is single-use for
+ * 90 s, so pass the previously accepted code as `lastCode` to wait for a new
+ * time step when necessary.
+ */
+export async function nextCode(secret: string, lastCode?: string) {
+ for (;;) {
+ const remaining = secondsUntilNextStep();
+
+ if (remaining <= SAFETY_MARGIN_SECONDS) {
+ await sleep(remaining * 1000 + 250);
+ continue;
+ }
+
+ const code = generateSync({ secret });
+
+ if (code !== lastCode) {
+ return code;
+ }
+
+ await sleep(remaining * 1000 + 250);
+ }
+}
+
+/** A 6-digit code that is guaranteed not to be the one currently valid for `secret`. */
+export function wrongCode(secret: string) {
+ const current = generateSync({ secret });
+
+ return current === '000000' ? '111111' : '000000';
+}
diff --git a/tests/api/links.spec.ts b/tests/api/links.spec.ts
new file mode 100644
index 000000000..ecb6f0916
--- /dev/null
+++ b/tests/api/links.spec.ts
@@ -0,0 +1,231 @@
+import { randomUUID } from 'node:crypto';
+import { expect, test } from './fixtures';
+import { ENTITY_TYPE, UNKNOWN_UUID } from './helpers/constants';
+import { dateRange } from './helpers/dates';
+import { deleteLink, uniqueName, uniqueSlug } from './helpers/entities';
+
+test.describe('Links', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let linkId = '';
+ let slug = '';
+ let shareId = '';
+
+ test.afterAll(async ({ admin }) => {
+ if (shareId) {
+ await admin.del(`/api/share/id/${shareId}`);
+ }
+
+ if (linkId) {
+ await deleteLink(admin, linkId);
+ }
+ });
+
+ test('POST /api/links creates a link for the current user', async ({ admin, seed }) => {
+ const name = uniqueName('link');
+ slug = uniqueSlug('link');
+ const response = await admin.post('/api/links', { name, url: 'https://umami.is/', slug });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ id: expect.any(String),
+ name,
+ url: 'https://umami.is/',
+ slug,
+ userId: seed.admin.id,
+ teamId: null,
+ createdAt: expect.any(String),
+ deletedAt: null,
+ });
+
+ linkId = response.body.id;
+ });
+
+ test('POST /api/links accepts a fixed id and a team', async ({ admin, seed }) => {
+ const id = randomUUID();
+ const response = await admin.post('/api/links', {
+ id,
+ name: uniqueName('team-link'),
+ url: 'https://umami.is/',
+ slug: uniqueSlug('team'),
+ teamId: seed.team.id,
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ id, teamId: seed.team.id, userId: null });
+
+ await deleteLink(admin, id);
+ });
+
+ test('POST /api/links validates the body and requires permission', async ({
+ admin,
+ viewer,
+ api,
+ }) => {
+ const shortSlug = await admin.post('/api/links', {
+ name: 'x',
+ url: 'https://umami.is/',
+ slug: 'short',
+ });
+ const missingUrl = await admin.post('/api/links', { name: 'x', slug: uniqueSlug('link') });
+ const denied = await viewer.post('/api/links', {
+ name: 'x',
+ url: 'https://umami.is/',
+ slug: uniqueSlug('link'),
+ });
+ const anonymous = await api.post('/api/links', {
+ name: 'x',
+ url: 'https://umami.is/',
+ slug: uniqueSlug('link'),
+ });
+
+ expect(shortSlug.status).toBe(400);
+ expect(shortSlug.body.error.code).toBe('bad-request');
+ expect(missingUrl.status).toBe(400);
+ expect(denied.status).toBe(401);
+ expect(anonymous.status).toBe(401);
+ });
+
+ test('GET /api/links lists the current user links', async ({ admin, user, viewer }) => {
+ const response = await admin.get('/api/links');
+ const search = await admin.get('/api/links', { params: { search: slug } });
+ const sorted = await admin.get('/api/links', {
+ params: { orderBy: 'createdAt', sortDescending: 'true', pageSize: 1 },
+ });
+ const invalidSort = await admin.get('/api/links', { params: { sortDescending: 'yes' } });
+ const other = await user.get('/api/links');
+ const none = await viewer.get('/api/links');
+
+ 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((l: any) => l.id)).toContain(linkId);
+ expect(search.body.data.map((l: any) => l.id)).toEqual([linkId]);
+ expect(sorted.status).toBe(200);
+ expect(sorted.body.data).toHaveLength(1);
+ expect(invalidSort.status).toBe(400);
+ expect(other.body.data.map((l: any) => l.id)).not.toContain(linkId);
+ expect(none.status).toBe(200);
+ expect(none.body.data).toHaveLength(0);
+ });
+
+ test('GET /api/links/{linkId} returns a link', async ({ admin, user, viewer, api }) => {
+ const response = await admin.get(`/api/links/${linkId}`);
+ const denied = await viewer.get(`/api/links/${linkId}`);
+ const anonymous = await api.get(`/api/links/${linkId}`);
+ const unknown = await user.get(`/api/links/${UNKNOWN_UUID}`);
+ // Admins bypass the ownership check, so an unknown id yields an empty 200.
+ const adminUnknown = await admin.get(`/api/links/${UNKNOWN_UUID}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ id: linkId, slug });
+ expect(denied.status).toBe(401);
+ expect(anonymous.status).toBe(401);
+ expect(unknown.status).toBe(401);
+ expect(adminUnknown.status).toBe(200);
+ expect(adminUnknown.body).toBeNull();
+ });
+
+ test('POST /api/links/{linkId} updates a link', async ({ admin, user, seed }) => {
+ const name = uniqueName('renamed');
+ slug = uniqueSlug('renamed');
+ const response = await admin.post(`/api/links/${linkId}`, {
+ name,
+ slug,
+ url: 'https://umami.is/docs',
+ });
+ const duplicate = await admin.post(`/api/links/${linkId}`, { slug: seed.link.slug });
+ const denied = await user.post(`/api/links/${linkId}`, { name });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ id: linkId, name, slug, url: 'https://umami.is/docs' });
+ expect(duplicate.status).toBe(400);
+ expect(duplicate.body.error.message).toBe('That slug is already taken.');
+ expect(denied.status).toBe(401);
+ });
+
+ test('POST /api/links/{linkId}/shares creates a link share', async ({ admin, user }) => {
+ const name = uniqueName('link-share');
+ const response = await admin.post(`/api/links/${linkId}/shares`, {
+ name,
+ parameters: { overview: true },
+ });
+ const invalid = await admin.post(`/api/links/${linkId}/shares`, {});
+ const denied = await user.post(`/api/links/${linkId}/shares`, { name });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ entityId: linkId,
+ shareType: ENTITY_TYPE.link,
+ name,
+ slug: expect.any(String),
+ parameters: { overview: true },
+ });
+ expect(invalid.status).toBe(400);
+ expect(denied.status).toBe(401);
+
+ shareId = response.body.id;
+ });
+
+ test('GET /api/links/{linkId}/shares lists link shares', async ({ admin, viewer, api }) => {
+ const response = await admin.get(`/api/links/${linkId}/shares`);
+ const paged = await admin.get(`/api/links/${linkId}/shares`, {
+ params: { page: 1, pageSize: 1 },
+ });
+ const denied = await viewer.get(`/api/links/${linkId}/shares`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ data: expect.any(Array), count: 1, page: 1 });
+ expect(response.body.data[0]).toMatchObject({ id: shareId, entityId: linkId });
+ expect(paged.body.pageSize).toBe(1);
+ expect(denied.status).toBe(401);
+
+ // The share resolves publicly to a link-typed token.
+ const resolved = await api.get(`/api/share/${response.body.data[0].slug}`);
+
+ expect(resolved.status).toBe(200);
+ expect(resolved.body).toMatchObject({ shareType: ENTITY_TYPE.link, linkId, websiteId: linkId });
+ });
+
+ test('GET /api/links/charts returns chart data for accessible links', async ({
+ admin,
+ viewer,
+ seed,
+ }) => {
+ const response = await admin.get('/api/links/charts', {
+ params: { ids: `${seed.link.id},${linkId}`, ...dateRange(seed), timezone: 'UTC' },
+ });
+ const defaultRange = await admin.get('/api/links/charts', { params: { ids: seed.link.id } });
+ const denied = await viewer.get('/api/links/charts', { params: { ids: seed.link.id } });
+ const invalid = await admin.get('/api/links/charts', { params: { ids: 'nope' } });
+ const missing = await admin.get('/api/links/charts');
+
+ expect(response.status).toBe(200);
+ // `data` is keyed by link id; ids the caller cannot view are silently dropped.
+ expect(Object.keys(response.body.data)).toContain(seed.link.id);
+ expect(response.body.data[seed.link.id]).toBeTruthy();
+ expect(defaultRange.status).toBe(200);
+ expect(denied.status).toBe(200);
+ expect(denied.body.data).toEqual({});
+ expect(invalid.status).toBe(400);
+ expect(missing.status).toBe(400);
+ });
+
+ test('DELETE /api/links/{linkId} deletes a link', async ({ admin, user }) => {
+ const denied = await user.del(`/api/links/${linkId}`);
+ const response = await admin.del(`/api/links/${linkId}`);
+ const gone = await admin.get(`/api/links/${linkId}`);
+
+ expect(denied.status).toBe(401);
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+ expect(gone.status).toBe(200);
+ expect(gone.body).toBeNull();
+
+ linkId = '';
+ });
+});
diff --git a/tests/api/paths.ts b/tests/api/paths.ts
new file mode 100644
index 000000000..b2f4adab1
--- /dev/null
+++ b/tests/api/paths.ts
@@ -0,0 +1,43 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
+
+/** Server under test (started by scripts/test-api.ts unless PLAYWRIGHT_BASE_URL is set). */
+export const BASE_URL =
+ process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${process.env.UMAMI_TEST_PORT ?? '3100'}`;
+
+/**
+ * Runtime state shared between global setup, workers and the coverage reporter.
+ * Namespaced by target host so concurrent runs against different servers (other
+ * ports, worktrees or sessions sharing this checkout) don't clobber each other.
+ */
+export const RUNTIME_DIR = path.join(
+ ROOT_DIR,
+ 'tests/api/.runtime',
+ new URL(BASE_URL).host.replace(/[^a-z0-9.-]+/gi, '-'),
+);
+export const COVERAGE_DIR = path.join(RUNTIME_DIR, 'coverage');
+export const SEED_FILE = path.join(RUNTIME_DIR, 'seed.json');
+export const OPENAPI_FILE = path.join(ROOT_DIR, 'public/openapi.json');
+
+const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
+
+/**
+ * The suite is destructive: global setup deletes and recreates fixed-ID websites
+ * and ingests analytics data, and specs toggle the global 2FA requirement. Only
+ * loopback targets (the local Compose stack) run without an explicit opt-in.
+ */
+export function assertDisposableTarget(baseURL: string) {
+ const { hostname } = new URL(baseURL);
+
+ if (LOOPBACK_HOSTS.has(hostname) || process.env.API_ALLOW_DESTRUCTIVE === '1') {
+ return;
+ }
+
+ throw new Error(
+ `Refusing to run the API suite against ${baseURL}: it deletes and recreates websites, ` +
+ 'ingests analytics data and changes the global 2FA setting. Point PLAYWRIGHT_BASE_URL ' +
+ 'at a disposable server and set API_ALLOW_DESTRUCTIVE=1 to confirm.',
+ );
+}
diff --git a/tests/api/pixels.spec.ts b/tests/api/pixels.spec.ts
new file mode 100644
index 000000000..fa7aba360
--- /dev/null
+++ b/tests/api/pixels.spec.ts
@@ -0,0 +1,217 @@
+import { randomUUID } from 'node:crypto';
+import { expect, test } from './fixtures';
+import { ENTITY_TYPE, UNKNOWN_UUID } from './helpers/constants';
+import { dateRange } from './helpers/dates';
+import { deletePixel, uniqueName, uniqueSlug } from './helpers/entities';
+
+test.describe('Pixels', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let pixelId = '';
+ let slug = '';
+ let shareId = '';
+
+ test.afterAll(async ({ admin }) => {
+ if (shareId) {
+ await admin.del(`/api/share/id/${shareId}`);
+ }
+
+ if (pixelId) {
+ await deletePixel(admin, pixelId);
+ }
+ });
+
+ test('POST /api/pixels creates a pixel for the current user', async ({ admin, seed }) => {
+ const name = uniqueName('pixel');
+ slug = uniqueSlug('pixel');
+ const response = await admin.post('/api/pixels', { name, slug });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ id: expect.any(String),
+ name,
+ slug,
+ userId: seed.admin.id,
+ teamId: null,
+ createdAt: expect.any(String),
+ deletedAt: null,
+ });
+
+ pixelId = response.body.id;
+ });
+
+ test('POST /api/pixels accepts a fixed id and a team', async ({ admin, seed }) => {
+ const id = randomUUID();
+ const response = await admin.post('/api/pixels', {
+ id,
+ name: uniqueName('team-pixel'),
+ slug: uniqueSlug('team'),
+ teamId: seed.team.id,
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ id, teamId: seed.team.id, userId: null });
+
+ await deletePixel(admin, id);
+ });
+
+ test('POST /api/pixels validates the body and requires permission', async ({
+ admin,
+ viewer,
+ api,
+ }) => {
+ const shortSlug = await admin.post('/api/pixels', { name: 'x', slug: 'short' });
+ const missingName = await admin.post('/api/pixels', { slug: uniqueSlug('pixel') });
+ const denied = await viewer.post('/api/pixels', { name: 'x', slug: uniqueSlug('pixel') });
+ const anonymous = await api.post('/api/pixels', { name: 'x', slug: uniqueSlug('pixel') });
+
+ expect(shortSlug.status).toBe(400);
+ expect(shortSlug.body.error.code).toBe('bad-request');
+ expect(missingName.status).toBe(400);
+ expect(denied.status).toBe(401);
+ expect(anonymous.status).toBe(401);
+ });
+
+ test('GET /api/pixels lists the current user pixels', async ({ admin, user, viewer }) => {
+ const response = await admin.get('/api/pixels');
+ const search = await admin.get('/api/pixels', { params: { search: slug } });
+ const sorted = await admin.get('/api/pixels', {
+ params: { orderBy: 'createdAt', sortDescending: 'true', pageSize: 1 },
+ });
+ const invalidSort = await admin.get('/api/pixels', { params: { sortDescending: 'yes' } });
+ const other = await user.get('/api/pixels');
+ const none = await viewer.get('/api/pixels');
+
+ 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((p: any) => p.id)).toContain(pixelId);
+ expect(search.body.data.map((p: any) => p.id)).toEqual([pixelId]);
+ expect(sorted.status).toBe(200);
+ expect(sorted.body.data).toHaveLength(1);
+ expect(invalidSort.status).toBe(400);
+ expect(other.body.data.map((p: any) => p.id)).not.toContain(pixelId);
+ expect(none.status).toBe(200);
+ expect(none.body.data).toHaveLength(0);
+ });
+
+ test('GET /api/pixels/{pixelId} returns a pixel', async ({ admin, user, viewer, api }) => {
+ const response = await admin.get(`/api/pixels/${pixelId}`);
+ const denied = await viewer.get(`/api/pixels/${pixelId}`);
+ const anonymous = await api.get(`/api/pixels/${pixelId}`);
+ const unknown = await user.get(`/api/pixels/${UNKNOWN_UUID}`);
+ // Admins bypass the ownership check, so an unknown id yields an empty 200.
+ const adminUnknown = await admin.get(`/api/pixels/${UNKNOWN_UUID}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ id: pixelId, slug });
+ expect(denied.status).toBe(401);
+ expect(anonymous.status).toBe(401);
+ expect(unknown.status).toBe(401);
+ expect(adminUnknown.status).toBe(200);
+ expect(adminUnknown.body).toBeNull();
+ });
+
+ test('POST /api/pixels/{pixelId} updates a pixel', async ({ admin, user, seed }) => {
+ const name = uniqueName('renamed');
+ slug = uniqueSlug('renamed');
+ const response = await admin.post(`/api/pixels/${pixelId}`, { name, slug });
+ const duplicate = await admin.post(`/api/pixels/${pixelId}`, { slug: seed.pixel.slug });
+ const denied = await user.post(`/api/pixels/${pixelId}`, { name });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ id: pixelId, name, slug });
+ expect(duplicate.status).toBe(400);
+ expect(duplicate.body.error.message).toBe('That slug is already taken.');
+ expect(denied.status).toBe(401);
+ });
+
+ test('POST /api/pixels/{pixelId}/shares creates a pixel share', async ({ admin, user }) => {
+ const name = uniqueName('pixel-share');
+ const response = await admin.post(`/api/pixels/${pixelId}/shares`, {
+ name,
+ parameters: { overview: true },
+ });
+ const invalid = await admin.post(`/api/pixels/${pixelId}/shares`, {});
+ const denied = await user.post(`/api/pixels/${pixelId}/shares`, { name });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ entityId: pixelId,
+ shareType: ENTITY_TYPE.pixel,
+ name,
+ slug: expect.any(String),
+ parameters: { overview: true },
+ });
+ expect(invalid.status).toBe(400);
+ expect(denied.status).toBe(401);
+
+ shareId = response.body.id;
+ });
+
+ test('GET /api/pixels/{pixelId}/shares lists pixel shares', async ({ admin, viewer, api }) => {
+ const response = await admin.get(`/api/pixels/${pixelId}/shares`);
+ const paged = await admin.get(`/api/pixels/${pixelId}/shares`, {
+ params: { page: 1, pageSize: 1 },
+ });
+ const denied = await viewer.get(`/api/pixels/${pixelId}/shares`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ data: expect.any(Array), count: 1, page: 1 });
+ expect(response.body.data[0]).toMatchObject({ id: shareId, entityId: pixelId });
+ expect(paged.body.pageSize).toBe(1);
+ expect(denied.status).toBe(401);
+
+ // The share resolves publicly to a pixel-typed token.
+ const resolved = await api.get(`/api/share/${response.body.data[0].slug}`);
+
+ expect(resolved.status).toBe(200);
+ expect(resolved.body).toMatchObject({
+ shareType: ENTITY_TYPE.pixel,
+ pixelId,
+ websiteId: pixelId,
+ });
+ });
+
+ test('GET /api/pixels/charts returns chart data for accessible pixels', async ({
+ admin,
+ viewer,
+ seed,
+ }) => {
+ const response = await admin.get('/api/pixels/charts', {
+ params: { ids: `${seed.pixel.id},${pixelId}`, ...dateRange(seed), timezone: 'UTC' },
+ });
+ const defaultRange = await admin.get('/api/pixels/charts', { params: { ids: seed.pixel.id } });
+ const denied = await viewer.get('/api/pixels/charts', { params: { ids: seed.pixel.id } });
+ const invalid = await admin.get('/api/pixels/charts', { params: { ids: 'nope' } });
+ const missing = await admin.get('/api/pixels/charts');
+
+ expect(response.status).toBe(200);
+ // `data` is keyed by pixel id; ids the caller cannot view are silently dropped.
+ expect(Object.keys(response.body.data)).toContain(seed.pixel.id);
+ expect(response.body.data[seed.pixel.id]).toBeTruthy();
+ expect(defaultRange.status).toBe(200);
+ expect(denied.status).toBe(200);
+ expect(denied.body.data).toEqual({});
+ expect(invalid.status).toBe(400);
+ expect(missing.status).toBe(400);
+ });
+
+ test('DELETE /api/pixels/{pixelId} deletes a pixel', async ({ admin, user }) => {
+ const denied = await user.del(`/api/pixels/${pixelId}`);
+ const response = await admin.del(`/api/pixels/${pixelId}`);
+ const gone = await admin.get(`/api/pixels/${pixelId}`);
+
+ expect(denied.status).toBe(401);
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+ expect(gone.status).toBe(200);
+ expect(gone.body).toBeNull();
+
+ pixelId = '';
+ });
+});
diff --git a/tests/api/replays.spec.ts b/tests/api/replays.spec.ts
new file mode 100644
index 000000000..c72c429b6
--- /dev/null
+++ b/tests/api/replays.spec.ts
@@ -0,0 +1,349 @@
+import { expect, test } from './fixtures';
+import { UNKNOWN_UUID } from './helpers/constants';
+import { dateRange } from './helpers/dates';
+import { uniqueName } from './helpers/entities';
+
+/**
+ * Global setup records one replay on `seed.website` (see seed/dataset.ts):
+ * three rrweb events spanning 20ms in a single chunk. Replay ids are visit ids.
+ * Other specs may record extra replays concurrently, so list assertions look for
+ * the seeded replay rather than asserting exact counts.
+ *
+ * Share tokens are rejected on every replay endpoint (session replay is never
+ * exposed through public shares).
+ */
+
+const REPLAY_EVENT_COUNT = 3;
+
+test.describe('Replays', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let base = '';
+
+ test.beforeAll(({ seed }) => {
+ base = `/api/websites/${seed.website.id}`;
+ });
+
+ // The seeded replay is shared with other specs: always leave it unsaved.
+ test.afterAll(async ({ admin, seed }) => {
+ await admin.post(`${base}/replays/saved/${seed.data.replayVisitId}`, { isSaved: false });
+ });
+
+ test('GET /api/websites/{websiteId}/replays lists recorded replays', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const response = await admin.get(`${base}/replays`, {
+ params: dateRange(seed, { pageSize: 100 }),
+ });
+ const longOnly = await admin.get(`${base}/replays`, {
+ params: dateRange(seed, { minDuration: 60_000, pageSize: 100 }),
+ });
+ const searched = await admin.get(`${base}/replays`, {
+ params: dateRange(seed, { search: 'zzz-no-such-replay' }),
+ });
+ const shared = await (await share()).get(`${base}/replays`, { params: dateRange(seed) });
+ const denied = await viewer.get(`${base}/replays`, { params: dateRange(seed) });
+ const missingRange = await admin.get(`${base}/replays`);
+ const badDuration = await admin.get(`${base}/replays`, {
+ params: dateRange(seed, { minDuration: -1 }),
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ data: expect.any(Array),
+ count: expect.any(Number),
+ page: 1,
+ pageSize: 100,
+ });
+
+ const replay = response.body.data.find((row: any) => row.id === seed.data.replayVisitId);
+ expect(replay).toEqual({
+ id: seed.data.replayVisitId,
+ websiteId: seed.website.id,
+ sessionId: seed.data.replaySessionId,
+ createdAt: expect.any(String),
+ startedAt: expect.any(String),
+ endedAt: expect.any(String),
+ duration: expect.any(Number),
+ eventCount: REPLAY_EVENT_COUNT,
+ chunkCount: 1,
+ browser: 'chrome',
+ os: expect.any(String),
+ device: expect.any(String),
+ // Unresolved geo is null on Postgres but '' on ClickHouse (non-nullable String columns).
+ country: seed.db === 'clickhouse' ? '' : null,
+ city: seed.db === 'clickhouse' ? '' : null,
+ });
+ expect(replay.duration).toBe(
+ new Date(replay.endedAt).getTime() - new Date(replay.startedAt).getTime(),
+ );
+
+ // The seeded replay only lasts 20ms.
+ expect(longOnly.status).toBe(200);
+ expect(longOnly.body.data.map((row: any) => row.id)).not.toContain(seed.data.replayVisitId);
+
+ expect(searched.status).toBe(200);
+ expect(searched.body.data).toHaveLength(0);
+
+ expect(shared.status).toBe(401);
+ expect(denied.status).toBe(401);
+ expect(missingRange.status).toBe(400);
+ expect(badDuration.status).toBe(400);
+ });
+
+ test('GET /api/websites/{websiteId}/replays/{replayId} returns the recorded events', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const response = await admin.get(`${base}/replays/${seed.data.replayVisitId}`);
+ const shared = await (await share()).get(`${base}/replays/${seed.data.replayVisitId}`);
+ const denied = await viewer.get(`${base}/replays/${seed.data.replayVisitId}`);
+ const unknown = await admin.get(`${base}/replays/${UNKNOWN_UUID}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ sessionId: seed.data.replaySessionId,
+ events: expect.any(Array),
+ startedAt: expect.any(String),
+ endedAt: expect.any(String),
+ eventCount: REPLAY_EVENT_COUNT,
+ chunkCount: 1,
+ });
+ expect(response.body.events).toHaveLength(REPLAY_EVENT_COUNT);
+ expect(response.body.events.map((event: any) => event.type)).toEqual([4, 2, 3]);
+ expect(response.body.events[0]).toEqual({
+ type: 4,
+ data: { href: `https://${seed.data.hostname}/`, width: 1280, height: 800 },
+ timestamp: expect.any(Number),
+ });
+ const timestamps = response.body.events.map((event: any) => event.timestamp);
+ // ClickHouse stores replay bounds at second precision; Postgres keeps milliseconds.
+ const bound = (value: number) =>
+ seed.db === 'clickhouse' ? Math.floor(value / 1000) * 1000 : value;
+ expect(bound(timestamps[0])).toBe(new Date(response.body.startedAt).getTime());
+ expect(bound(timestamps[timestamps.length - 1])).toBe(
+ new Date(response.body.endedAt).getTime(),
+ );
+
+ // Unknown replays are an empty recording, not a 404.
+ expect(unknown.status).toBe(200);
+ expect(unknown.body).toEqual({
+ sessionId: null,
+ events: [],
+ startedAt: null,
+ endedAt: null,
+ eventCount: 0,
+ chunkCount: 0,
+ });
+
+ expect(shared.status).toBe(401);
+ expect(denied.status).toBe(401);
+ });
+
+ test('GET /api/websites/{websiteId}/replays/{replayId} truncates the events with until, chunkIndex and eventIndex', async ({
+ admin,
+ seed,
+ }) => {
+ const path = `${base}/replays/${seed.data.replayVisitId}`;
+ const full = await admin.get(path);
+ const secondTimestamp = full.body.events[1].timestamp;
+
+ // `chunkIndex` is not an ordinal: /api/record stores each chunk under its ingest
+ // timestamp in seconds (`timestamp || Date.now() / 1000`), so the parameter is an
+ // inclusive upper bound on chunk timestamps.
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ const startSeconds = Math.floor(new Date(full.body.startedAt).getTime() / 1000);
+
+ const until = await admin.get(path, { params: { until: secondTimestamp } });
+ const allChunks = await admin.get(path, { params: { chunkIndex: nowSeconds } });
+ const beforeFirstChunk = await admin.get(path, { params: { chunkIndex: startSeconds - 1 } });
+ const listed = await admin.get(`${base}/replays`, {
+ params: dateRange(seed, { pageSize: 100 }),
+ });
+
+ expect(until.status).toBe(200);
+ expect(until.body.events.map((event: any) => event.timestamp)).toEqual(
+ full.body.events
+ .map((event: any) => event.timestamp)
+ .filter((timestamp: number) => timestamp <= secondTimestamp),
+ );
+ expect(until.body.events.length).toBeLessThan(REPLAY_EVENT_COUNT);
+
+ expect(allChunks.status).toBe(200);
+ expect(allChunks.body.events).toEqual(full.body.events);
+ expect(allChunks.body.chunkCount).toBe(1);
+
+ expect(beforeFirstChunk.status).toBe(200);
+ expect(beforeFirstChunk.body).toMatchObject({ events: [], chunkCount: 0 });
+
+ // `eventIndex` only applies to the chunk whose timestamp equals `chunkIndex`. The
+ // seeded chunk was ingested without an explicit timestamp, so its key is the ingest
+ // second, which sits within a second or two of the replay's `createdAt`.
+ const replay = listed.body.data.find((row: any) => row.id === seed.data.replayVisitId);
+ const createdSeconds = Math.floor(new Date(replay.createdAt).getTime() / 1000);
+ const truncated: any[] = [];
+
+ for (const chunkIndex of [createdSeconds - 2, createdSeconds - 1, createdSeconds]) {
+ const response = await admin.get(path, { params: { chunkIndex, eventIndex: 0 } });
+
+ expect(response.status).toBe(200);
+ truncated.push(response.body.events);
+ }
+
+ expect(truncated).toContainEqual([full.body.events[0]]);
+ });
+
+ test('POST /api/websites/{websiteId}/replays/saved/{replayId} saves a replay', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const name = uniqueName('saved-replay');
+
+ // Saving an already-saved replay fails with a unique-constraint 500 (it is not an
+ // upsert), so start from a known unsaved state in case an earlier run aborted.
+ await admin.post(`${base}/replays/saved/${seed.data.replayVisitId}`, { isSaved: false });
+
+ const response = await admin.post(`${base}/replays/saved/${seed.data.replayVisitId}`, {
+ isSaved: true,
+ name,
+ });
+ const shared = await (await share()).post(`${base}/replays/saved/${seed.data.replayVisitId}`, {
+ isSaved: true,
+ });
+ const denied = await viewer.post(`${base}/replays/saved/${seed.data.replayVisitId}`, {
+ isSaved: true,
+ });
+ const missingFlag = await admin.post(`${base}/replays/saved/${seed.data.replayVisitId}`, {
+ name,
+ });
+ const longName = await admin.post(`${base}/replays/saved/${seed.data.replayVisitId}`, {
+ isSaved: true,
+ name: 'x'.repeat(101),
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+ expect(shared.status).toBe(401);
+ expect(denied.status).toBe(401);
+ expect(missingFlag.status).toBe(400);
+ expect(longName.status).toBe(400);
+
+ const status = await admin.get(`${base}/replays/saved/${seed.data.replayVisitId}`);
+ const saved = await admin.get(`${base}/replays/saved`, { params: { pageSize: 100 } });
+ const searched = await admin.get(`${base}/replays/saved`, { params: { search: name } });
+ const missed = await admin.get(`${base}/replays/saved`, {
+ params: { search: 'zzz-no-such-saved-replay' },
+ });
+ const listShared = await (await share()).get(`${base}/replays/saved`);
+ const listDenied = await viewer.get(`${base}/replays/saved`);
+ const statusShared = await (await share()).get(
+ `${base}/replays/saved/${seed.data.replayVisitId}`,
+ );
+ const statusDenied = await viewer.get(`${base}/replays/saved/${seed.data.replayVisitId}`);
+
+ expect(status.status).toBe(200);
+ expect(status.body.isSaved).toBeTruthy();
+
+ expect(saved.status).toBe(200);
+ expect(saved.body).toMatchObject({ data: expect.any(Array), count: expect.any(Number) });
+ expect(saved.body.data).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: expect.any(String),
+ websiteId: seed.website.id,
+ visitId: seed.data.replayVisitId,
+ name,
+ createdAt: expect.any(String),
+ }),
+ ]),
+ );
+
+ expect(searched.status).toBe(200);
+ expect(searched.body.data).toHaveLength(1);
+ expect(searched.body.data[0]).toMatchObject({ visitId: seed.data.replayVisitId, name });
+ expect(missed.body.data).toHaveLength(0);
+
+ expect(listShared.status).toBe(401);
+ expect(listDenied.status).toBe(401);
+ expect(statusShared.status).toBe(401);
+ expect(statusDenied.status).toBe(401);
+ });
+
+ test('POST /api/websites/{websiteId}/replays/saved/{replayId} unsaves a replay', async ({
+ admin,
+ seed,
+ }) => {
+ const response = await admin.post(`${base}/replays/saved/${seed.data.replayVisitId}`, {
+ isSaved: false,
+ });
+ const status = await admin.get(`${base}/replays/saved/${seed.data.replayVisitId}`);
+ const saved = await admin.get(`${base}/replays/saved`, { params: { pageSize: 100 } });
+ const unknown = await admin.get(`${base}/replays/saved/${UNKNOWN_UUID}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+ expect(status.status).toBe(200);
+ expect(status.body.isSaved).toBeFalsy();
+ expect(saved.body.data.map((row: any) => row.visitId)).not.toContain(seed.data.replayVisitId);
+ expect(unknown.status).toBe(200);
+ expect(unknown.body.isSaved).toBeFalsy();
+ });
+
+ test('GET /api/websites/{websiteId}/sessions/{sessionId}/replays lists the replays of a session', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const path = `${base}/sessions/${seed.data.replaySessionId}/replays`;
+ const response = await admin.get(path, { params: dateRange(seed) });
+ const searched = await admin.get(path, {
+ params: dateRange(seed, { search: 'zzz-no-such-replay' }),
+ });
+ const none = await admin.get(`${base}/sessions/${UNKNOWN_UUID}/replays`, {
+ params: dateRange(seed),
+ });
+ const shared = await (await share()).get(path, { params: dateRange(seed) });
+ const denied = await viewer.get(path, { params: dateRange(seed) });
+ const missingRange = await admin.get(path);
+
+ 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.length).toBeGreaterThan(0);
+ for (const row of response.body.data) {
+ expect(row).toMatchObject({
+ id: expect.any(String),
+ websiteId: seed.website.id,
+ sessionId: seed.data.replaySessionId,
+ eventCount: expect.any(Number),
+ chunkCount: expect.any(Number),
+ duration: expect.any(Number),
+ startedAt: expect.any(String),
+ endedAt: expect.any(String),
+ });
+ }
+ expect(response.body.data.map((row: any) => row.id)).toContain(seed.data.replayVisitId);
+
+ expect(searched.status).toBe(200);
+ expect(searched.body.data).toHaveLength(0);
+
+ expect(none.status).toBe(200);
+ expect(none.body.data).toHaveLength(0);
+
+ expect(shared.status).toBe(401);
+ expect(denied.status).toBe(401);
+ expect(missingRange.status).toBe(400);
+ });
+});
diff --git a/tests/api/reports.spec.ts b/tests/api/reports.spec.ts
new file mode 100644
index 000000000..334cf3b7f
--- /dev/null
+++ b/tests/api/reports.spec.ts
@@ -0,0 +1,650 @@
+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);
+ });
+});
diff --git a/tests/api/revenue.spec.ts b/tests/api/revenue.spec.ts
new file mode 100644
index 000000000..dfcd82b1d
--- /dev/null
+++ b/tests/api/revenue.spec.ts
@@ -0,0 +1,245 @@
+import { expect, test } from './fixtures';
+import { dateRange, TIMEZONE } from './helpers/dates';
+
+/**
+ * Seeded revenue on `seed.website` (see seed/dataset.ts): the 3 identified personas
+ * each send a `purchase` on 3 days with revenue 49.5 / 59.5 / 69.5 in USD.
+ * 9 purchases, sum 535.5, average 59.5. Nothing else in the suite ingests
+ * `revenue` for this website, so these totals are asserted exactly.
+ */
+
+const PURCHASE_REVENUES = [49.5, 59.5, 69.5];
+const PURCHASE_COUNT = 9;
+const PURCHASE_TOTAL = PURCHASE_REVENUES.reduce((sum, value) => sum + value, 0) * 3;
+
+test.describe('Revenue', () => {
+ let base = '';
+
+ test.beforeAll(({ seed }) => {
+ base = `/api/websites/${seed.website.id}`;
+ });
+
+ test('GET /api/websites/{websiteId}/revenue/chart returns revenue per event over time', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const params = dateRange(seed, {
+ currency: seed.data.currency,
+ unit: 'day',
+ timezone: TIMEZONE,
+ });
+ const response = await admin.get(`${base}/revenue/chart`, { params });
+ const shared = await (await share()).get(`${base}/revenue/chart`, { params });
+ const denied = await viewer.get(`${base}/revenue/chart`, { params });
+ const missingCurrency = await admin.get(`${base}/revenue/chart`, {
+ params: dateRange(seed),
+ });
+ const missingRange = await admin.get(`${base}/revenue/chart`, {
+ params: { currency: seed.data.currency },
+ });
+
+ expect(response.status).toBe(200);
+ expect(Object.keys(response.body)).toEqual(['chart']);
+ // Purchases land on 3 distinct days, 3 per day.
+ expect(response.body.chart).toHaveLength(3);
+ for (const point of response.body.chart) {
+ expect(point).toEqual({
+ x: 'purchase',
+ t: expect.any(String),
+ y: expect.anything(),
+ count: 3,
+ });
+ expect(Number(point.y)).toBeCloseTo(PURCHASE_TOTAL / 3, 5);
+ }
+
+ expect(shared.status).toBe(200);
+ expect(denied.status).toBe(401);
+ expect(missingCurrency.status).toBe(400);
+ expect(missingRange.status).toBe(400);
+ });
+
+ test('GET /api/websites/{websiteId}/revenue/metrics breaks revenue down by a dimension', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const params = dateRange(seed, { currency: seed.data.currency });
+ const byType: Record = {};
+
+ for (const type of ['country', 'region', 'referrer', 'channel']) {
+ byType[type] = await admin.get(`${base}/revenue/metrics`, { params: { ...params, type } });
+ }
+
+ const shared = await (await share()).get(`${base}/revenue/metrics`, {
+ params: { ...params, type: 'country' },
+ });
+ const denied = await viewer.get(`${base}/revenue/metrics`, {
+ params: { ...params, type: 'country' },
+ });
+ const badType = await admin.get(`${base}/revenue/metrics`, {
+ params: { ...params, type: 'browser' },
+ });
+ const missingType = await admin.get(`${base}/revenue/metrics`, { params });
+ const missingCurrency = await admin.get(`${base}/revenue/metrics`, {
+ params: dateRange(seed, { type: 'country' }),
+ });
+
+ for (const [type, response] of Object.entries(byType)) {
+ expect(response.status, type).toBe(200);
+ expect(Array.isArray(response.body), type).toBe(true);
+ expect(response.body.length, type).toBeGreaterThan(0);
+ for (const row of response.body) {
+ // Region rows also carry the parent `country`.
+ expect(Object.keys(row).sort(), type).toEqual(
+ type === 'region' ? ['country', 'name', 'value'] : ['name', 'value'],
+ );
+ }
+ // Every breakdown adds up to the full seeded revenue.
+ const total = response.body.reduce((sum: number, row: any) => sum + Number(row.value), 0);
+ expect(total, type).toBeCloseTo(PURCHASE_TOTAL, 5);
+ }
+
+ // The test image has no GeoIP database, so country/region are unresolved
+ // (null on Postgres, '' on ClickHouse's non-nullable String columns).
+ const unresolved = seed.db === 'clickhouse' ? '' : null;
+ expect(byType.country.body).toEqual([{ name: unresolved, value: expect.anything() }]);
+ expect(byType.region.body).toEqual([
+ { country: unresolved, name: unresolved, value: expect.anything() },
+ ]);
+
+ // alice (google.com, utm+gclid => paidAds) buys 3 x 49.5, bob (t.co => organicSocial)
+ // 3 x 59.5 and carol (no referrer, no query string => direct) 3 x 69.5.
+ const asMap = (rows: any[]) =>
+ Object.fromEntries(rows.map(row => [String(row.name), Number(row.value)]));
+ expect(asMap(byType.referrer.body)).toEqual({
+ 'google.com': 148.5,
+ 't.co': 178.5,
+ // No referrer is null on Postgres and '' on ClickHouse (see `unresolved` above).
+ [String(unresolved)]: 208.5,
+ });
+ expect(asMap(byType.channel.body)).toEqual({
+ paidAds: 148.5,
+ organicSocial: 178.5,
+ direct: 208.5,
+ });
+
+ expect(shared.status).toBe(200);
+ expect(denied.status).toBe(401);
+ expect(badType.status).toBe(400);
+ expect(missingType.status).toBe(400);
+ expect(missingCurrency.status).toBe(400);
+ });
+
+ test('GET /api/websites/{websiteId}/revenue/sessions lists sessions with revenue', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const params = dateRange(seed, { currency: seed.data.currency });
+ const response = await admin.get(`${base}/revenue/sessions`, {
+ params: { ...params, pageSize: 100 },
+ });
+ const paged = await admin.get(`${base}/revenue/sessions`, {
+ params: { ...params, page: 2, pageSize: 2 },
+ });
+ const searched = await admin.get(`${base}/revenue/sessions`, {
+ params: { ...params, search: 'zzz-no-such-session' },
+ });
+ const shared = await (await share()).get(`${base}/revenue/sessions`, { params });
+ const denied = await viewer.get(`${base}/revenue/sessions`, { params });
+ const missingCurrency = await admin.get(`${base}/revenue/sessions`, {
+ params: dateRange(seed),
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ data: expect.any(Array),
+ count: 3,
+ page: 1,
+ pageSize: 100,
+ });
+ expect(response.body.data).toHaveLength(3);
+ for (const row of response.body.data) {
+ expect(row).toMatchObject({
+ id: expect.any(String),
+ websiteId: seed.website.id,
+ hostname: seed.data.hostname,
+ browser: expect.any(String),
+ os: expect.any(String),
+ device: expect.any(String),
+ firstAt: expect.any(String),
+ lastAt: expect.any(String),
+ createdAt: expect.any(String),
+ });
+ }
+
+ expect(paged.status).toBe(200);
+ expect(paged.body).toMatchObject({ count: 3, page: 2, pageSize: 2 });
+ expect(paged.body.data).toHaveLength(1);
+
+ expect(searched.status).toBe(200);
+ expect(searched.body.data).toHaveLength(0);
+
+ expect(shared.status).toBe(200);
+ expect(denied.status).toBe(401);
+ expect(missingCurrency.status).toBe(400);
+ });
+
+ test('GET /api/websites/{websiteId}/revenue/stats returns revenue totals', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const params = dateRange(seed, { currency: seed.data.currency });
+ const response = await admin.get(`${base}/revenue/stats`, { params });
+ const otherCurrency = await admin.get(`${base}/revenue/stats`, {
+ params: { ...params, currency: 'EUR' },
+ });
+ const shared = await (await share()).get(`${base}/revenue/stats`, { params });
+ const denied = await viewer.get(`${base}/revenue/stats`, { params });
+ const missingCurrency = await admin.get(`${base}/revenue/stats`, { params: dateRange(seed) });
+ const missingRange = await admin.get(`${base}/revenue/stats`, {
+ params: { currency: seed.data.currency },
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ count: PURCHASE_COUNT,
+ unique_count: 3,
+ total_sessions: expect.any(Number),
+ });
+ // The comparison window precedes the seeded range and holds no revenue.
+ expect(Object.keys(response.body.comparison).sort()).toEqual([
+ 'arpu',
+ 'average',
+ 'count',
+ 'sum',
+ 'total_sessions',
+ 'unique_count',
+ ]);
+ expect(response.body.comparison.count).toBe(0);
+ expect(Number(response.body.comparison.sum ?? 0)).toBe(0);
+ expect(Number(response.body.sum)).toBeCloseTo(PURCHASE_TOTAL, 5);
+ expect(Number(response.body.average)).toBeCloseTo(PURCHASE_TOTAL / PURCHASE_COUNT, 5);
+ expect(response.body.total_sessions).toBeGreaterThanOrEqual(3);
+ expect(Number(response.body.arpu)).toBeCloseTo(
+ PURCHASE_TOTAL / response.body.total_sessions,
+ 5,
+ );
+
+ // Nothing was ingested in EUR.
+ expect(otherCurrency.status).toBe(200);
+ expect(otherCurrency.body.count).toBe(0);
+ expect(Number(otherCurrency.body.sum ?? 0)).toBe(0);
+
+ expect(shared.status).toBe(200);
+ expect(denied.status).toBe(401);
+ expect(missingCurrency.status).toBe(400);
+ expect(missingRange.status).toBe(400);
+ });
+});
diff --git a/tests/api/seed/dataset.ts b/tests/api/seed/dataset.ts
new file mode 100644
index 000000000..71ab1c61e
--- /dev/null
+++ b/tests/api/seed/dataset.ts
@@ -0,0 +1,412 @@
+import { SEED_IDS } from './ids';
+
+/**
+ * Deterministic analytics dataset ingested through the real collection
+ * endpoints (/api/send, /api/batch, /api/record). Works for both Postgres and
+ * ClickHouse. Backdated events use `timestamp` (seconds).
+ *
+ * IPs are private on purpose: the test image has no GeoLite database and
+ * src/lib/detect.ts throws on a non-local IP lookup.
+ */
+
+export interface SendPayload {
+ type: 'event' | 'identify' | 'performance';
+ payload: Record;
+}
+
+export interface RecordPayload {
+ type: 'record' | 'heatmap';
+ payload: Record;
+}
+
+/** Events sent sequentially, chained with the `x-umami-cache` token. */
+export interface Visit {
+ events: SendPayload[];
+}
+
+export interface Dataset {
+ range: { startAt: number; endAt: number };
+ visits: Visit[];
+ batch: SendPayload[];
+ replay: { pageview: SendPayload; record: RecordPayload; heatmap: RecordPayload };
+ realtime: SendPayload[];
+ expected: { pageviews: number; website2Pageviews: number };
+ distinctIds: string[];
+ pages: string[];
+ eventNames: string[];
+}
+
+export interface Persona {
+ id: string;
+ ip: string;
+ userAgent: string;
+ screen: string;
+ language: string;
+ referrer: string;
+ utm?: boolean;
+ tag?: string;
+}
+
+export const HOSTNAME = 'example.test';
+export const CURRENCY = 'USD';
+export const DAYS = 7;
+export const EVENT_NAMES = ['signup', 'purchase', 'download'];
+export const UTM_QUERY = 'utm_source=newsletter&utm_medium=email&utm_campaign=launch&gclid=abc123';
+
+/** The first N personas send `identify` calls and carry a distinct id. */
+const IDENTIFIED_PERSONAS = 3;
+const DAY = 86_400;
+const HOUR = 3_600;
+
+export const PAGES = [
+ { url: '/', title: 'Home' },
+ { url: '/pricing', title: 'Pricing' },
+ { url: '/blog/hello-world', title: 'Hello World' },
+ { url: '/docs', title: 'Documentation' },
+ { url: '/signup', title: 'Sign up' },
+];
+
+export const PERSONAS: Persona[] = [
+ {
+ id: 'alice',
+ ip: '10.0.0.1',
+ userAgent:
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
+ screen: '1920x1080',
+ language: 'en-US',
+ referrer: 'https://www.google.com/',
+ utm: true,
+ },
+ {
+ id: 'bob',
+ ip: '10.0.0.2',
+ userAgent:
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15',
+ screen: '2560x1440',
+ language: 'en-GB',
+ referrer: 'https://t.co/abc123',
+ },
+ {
+ id: 'carol',
+ ip: '10.0.0.3',
+ userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0',
+ screen: '1440x900',
+ language: 'de-DE',
+ referrer: '',
+ tag: 'beta',
+ },
+ {
+ id: 'dave',
+ ip: '10.0.0.4',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
+ screen: '412x915',
+ language: 'fr-FR',
+ referrer: 'https://www.bing.com/search?q=umami',
+ utm: true,
+ },
+ {
+ id: 'erin',
+ ip: '10.0.0.5',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1',
+ screen: '390x844',
+ language: 'es-ES',
+ referrer: '',
+ },
+ {
+ id: 'frank',
+ ip: '10.0.0.6',
+ userAgent:
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0',
+ screen: '1366x768',
+ language: 'en-US',
+ referrer: 'https://github.com/umami-software/umami',
+ },
+];
+
+export function getDistinctId(persona: Persona) {
+ return `user-${persona.id}`;
+}
+
+function client(persona: Persona) {
+ return {
+ hostname: HOSTNAME,
+ language: persona.language,
+ screen: persona.screen,
+ userAgent: persona.userAgent,
+ ip: persona.ip,
+ };
+}
+
+function identity(persona: Persona, index: number) {
+ return index < IDENTIFIED_PERSONAS ? { id: getDistinctId(persona) } : {};
+}
+
+function pageview(
+ website: string,
+ persona: Persona,
+ index: number,
+ page: { url: string; title: string },
+ extra: Record,
+): SendPayload {
+ return {
+ type: 'event',
+ payload: { website, ...client(persona), ...identity(persona, index), ...page, ...extra },
+ };
+}
+
+function customEvent(
+ website: string,
+ persona: Persona,
+ index: number,
+ name: string,
+ data: Record,
+ timestamp: number,
+): SendPayload {
+ return {
+ type: 'event',
+ payload: {
+ website,
+ ...client(persona),
+ ...identity(persona, index),
+ url: '/',
+ title: 'Home',
+ name,
+ data,
+ timestamp,
+ },
+ };
+}
+
+export function buildDataset(now = Date.now()): Dataset {
+ const nowSec = Math.floor(now / 1000);
+ const range = { startAt: (nowSec - DAYS * DAY) * 1000, endAt: now };
+ const visits: Visit[] = [];
+ const batch: SendPayload[] = [];
+ let pageviews = 0;
+ let website2Pageviews = 0;
+
+ // Backdated traffic: days 1..6 ago (today only receives the live events below).
+ for (let day = DAYS - 1; day >= 1; day--) {
+ PERSONAS.forEach((persona, index) => {
+ const start = nowSec - day * DAY - (index + 1) * HOUR;
+ const pageCount = 2 + ((day + index) % 3);
+ const pages = PAGES.slice(0, pageCount);
+
+ visits.push({
+ events: pages.map((page, step) =>
+ pageview(SEED_IDS.website, persona, index, page, {
+ url: step === 0 && persona.utm ? `${page.url}?${UTM_QUERY}` : page.url,
+ referrer: step === 0 && persona.referrer ? persona.referrer : undefined,
+ tag: persona.tag,
+ timestamp: start + step * 60,
+ }),
+ ),
+ });
+ pageviews += pages.length;
+
+ const afterPages = start + pageCount * 60;
+
+ if (day === 2) {
+ batch.push(
+ customEvent(
+ SEED_IDS.website,
+ persona,
+ index,
+ 'signup',
+ { plan: index % 2 ? 'pro' : 'free', seats: 1 + index },
+ afterPages,
+ ),
+ );
+ }
+
+ if (index < IDENTIFIED_PERSONAS && [1, 3, 5].includes(day)) {
+ batch.push(
+ customEvent(
+ SEED_IDS.website,
+ persona,
+ index,
+ 'purchase',
+ {
+ revenue: 49.5 + index * 10,
+ currency: CURRENCY,
+ quantity: 2,
+ items: ['sku-1', 'sku-2'],
+ orderedAt: new Date(afterPages * 1000).toISOString(),
+ premium: index === 0,
+ },
+ afterPages + 30,
+ ),
+ );
+ }
+
+ if (day === 4) {
+ batch.push(
+ customEvent(
+ SEED_IDS.website,
+ persona,
+ index,
+ 'download',
+ { file: 'guide.pdf' },
+ afterPages + 60,
+ ),
+ );
+ }
+
+ if (index < IDENTIFIED_PERSONAS && day === DAYS - 1) {
+ batch.push({
+ type: 'identify',
+ payload: {
+ website: SEED_IDS.website,
+ ...client(persona),
+ id: getDistinctId(persona),
+ data: {
+ plan: 'pro',
+ seats: 5 + index,
+ tags: ['early-adopter', 'newsletter'],
+ signedUpAt: new Date(afterPages * 1000).toISOString(),
+ active: true,
+ },
+ timestamp: afterPages + 90,
+ },
+ });
+ }
+
+ if (day % 2 === 0) {
+ batch.push({
+ type: 'performance',
+ payload: {
+ website: SEED_IDS.website,
+ ...client(persona),
+ url: '/',
+ title: 'Home',
+ lcp: 1800 + index * 100,
+ inp: 120 + index * 10,
+ cls: 0.05,
+ fcp: 900 + index * 50,
+ ttfb: 250 + index * 20,
+ timestamp: start + 5,
+ },
+ });
+ }
+ });
+ }
+
+ // Secondary website (owned by the regular user): two personas, five days.
+ for (let day = 5; day >= 1; day--) {
+ PERSONAS.slice(0, 2).forEach((persona, index) => {
+ const timestamp = nowSec - day * DAY - (index + 1) * HOUR;
+
+ visits.push({
+ events: [
+ pageview(SEED_IDS.website2, persona, index, PAGES[day % PAGES.length], { timestamp }),
+ ],
+ });
+ website2Pageviews += 1;
+ });
+ }
+
+ batch.push(
+ customEvent(
+ SEED_IDS.website2,
+ PERSONAS[0],
+ 0,
+ 'purchase',
+ { revenue: 19.99, currency: CURRENCY, quantity: 1 },
+ nowSec - 2 * DAY,
+ ),
+ );
+
+ // Link clicks and pixel hits (sessions are keyed by the link/pixel id).
+ PERSONAS.slice(0, 5).forEach((persona, index) => {
+ const timestamp = nowSec - (index + 1) * DAY - 30 * 60;
+
+ batch.push({
+ type: 'event',
+ payload: { link: SEED_IDS.link, ...client(persona), url: '/', timestamp },
+ });
+ batch.push({
+ type: 'event',
+ payload: { pixel: SEED_IDS.pixel, ...client(persona), url: '/', timestamp: timestamp + 60 },
+ });
+ });
+
+ // Live traffic (no timestamp): one recorded visit plus a few realtime pageviews.
+ const replayPersona = PERSONAS[0];
+ const replay = {
+ pageview: pageview(SEED_IDS.website, replayPersona, 0, PAGES[0], {}),
+ record: {
+ type: 'record' as const,
+ payload: {
+ website: SEED_IDS.website,
+ events: [
+ {
+ type: 4,
+ data: { href: `https://${HOSTNAME}/`, width: 1280, height: 800 },
+ timestamp: now,
+ },
+ {
+ type: 2,
+ data: { node: { type: 0, childNodes: [] }, initialOffset: { left: 0, top: 0 } },
+ timestamp: now + 10,
+ },
+ {
+ type: 3,
+ data: { source: 1, positions: [{ x: 10, y: 20, id: 1, timeOffset: 0 }] },
+ timestamp: now + 20,
+ },
+ ],
+ },
+ },
+ heatmap: {
+ type: 'heatmap' as const,
+ payload: {
+ website: SEED_IDS.website,
+ events: [
+ {
+ type: 'click',
+ url: `https://${HOSTNAME}/`,
+ x: 100,
+ y: 200,
+ pageX: 100,
+ pageY: 200,
+ pageW: 1280,
+ pageH: 3000,
+ viewportW: 1280,
+ viewportH: 800,
+ timestamp: now + 30,
+ },
+ {
+ type: 'scroll',
+ url: `https://${HOSTNAME}/`,
+ scrollPct: 60,
+ pageW: 1280,
+ pageH: 3000,
+ viewportW: 1280,
+ viewportH: 800,
+ timestamp: now + 40,
+ },
+ ],
+ },
+ },
+ };
+ pageviews += 1;
+
+ const realtime = PERSONAS.slice(1).map((persona, index) =>
+ pageview(SEED_IDS.website, persona, index + 1, PAGES[index % PAGES.length], {}),
+ );
+ pageviews += realtime.length;
+
+ return {
+ range,
+ visits,
+ batch,
+ replay,
+ realtime,
+ expected: { pageviews, website2Pageviews },
+ distinctIds: PERSONAS.slice(0, IDENTIFIED_PERSONAS).map(getDistinctId),
+ pages: PAGES.map(page => page.url),
+ eventNames: EVENT_NAMES,
+ };
+}
diff --git a/tests/api/seed/ids.ts b/tests/api/seed/ids.ts
new file mode 100644
index 000000000..82c37ab57
--- /dev/null
+++ b/tests/api/seed/ids.ts
@@ -0,0 +1,27 @@
+/**
+ * Fixed identifiers for seeded entities. Using fixed UUIDs lets global setup
+ * find and recreate them on re-runs against a kept stack (`--keep`).
+ */
+export const SEED_IDS = {
+ website: '1e5a0000-0000-4000-8000-000000000001',
+ website2: '1e5a0000-0000-4000-8000-000000000002',
+ link: '1e5a0000-0000-4000-8000-000000000003',
+ pixel: '1e5a0000-0000-4000-8000-000000000004',
+} as const;
+
+export const SEED_USERS = {
+ /** Regular user; owns the secondary website. */
+ user: { username: 'api-user', password: 'api-user-password', role: 'user' },
+ /** View-only user that owns nothing — used for permission-denied cases. */
+ viewer: { username: 'api-viewer', password: 'api-viewer-password', role: 'view-only' },
+} as const;
+
+export const SEED_WEBSITES = {
+ primary: { name: 'API Test Website', domain: 'example.test' },
+ secondary: { name: 'API Test Website (user)', domain: 'user.example.test' },
+} as const;
+
+export const SEED_LINK = { name: 'API Test Link', url: 'https://umami.is/', slug: 'apitestlink' };
+export const SEED_PIXEL = { name: 'API Test Pixel', slug: 'apitestpixel' };
+export const SEED_TEAM = { name: 'API Test Team' };
+export const SEED_SHARE = { name: 'API Test Share' };
diff --git a/tests/api/seed/ingest.ts b/tests/api/seed/ingest.ts
new file mode 100644
index 000000000..d901efab4
--- /dev/null
+++ b/tests/api/seed/ingest.ts
@@ -0,0 +1,88 @@
+import type { ApiClient } from '../client';
+import { CACHE_HEADER } from '../helpers/constants';
+import type { Dataset, RecordPayload, SendPayload } from './dataset';
+
+export interface IngestResult {
+ replay: { sessionId: string; visitId: string };
+}
+
+const BATCH_LIMIT = 500;
+
+function chunk(items: T[], size: number) {
+ const chunks: T[][] = [];
+
+ for (let i = 0; i < items.length; i += size) {
+ chunks.push(items.slice(i, i + size));
+ }
+
+ return chunks;
+}
+
+async function send(api: ApiClient, event: SendPayload, cache?: string) {
+ const response = await api.post('/api/send', event, {
+ headers: cache ? { [CACHE_HEADER]: cache } : undefined,
+ });
+
+ if (response.status !== 200) {
+ throw new Error(`POST /api/send failed (${response.status}): ${response.text}`);
+ }
+
+ if (response.body?.beep) {
+ throw new Error(
+ 'POST /api/send classified the request as a bot. Start the server with DISABLE_BOT_CHECK=1.',
+ );
+ }
+
+ if (!response.body?.cache) {
+ throw new Error(`POST /api/send returned no cache token: ${response.text}`);
+ }
+
+ return response.body as { cache: string; sessionId: string; visitId: string };
+}
+
+async function record(api: ApiClient, payload: RecordPayload, cache: string) {
+ const response = await api.post('/api/record', payload, { headers: { [CACHE_HEADER]: cache } });
+
+ if (response.status !== 200 || response.body?.ok !== true) {
+ throw new Error(
+ `POST /api/record (${payload.type}) failed (${response.status}): ${response.text}`,
+ );
+ }
+}
+
+export async function ingestDataset(api: ApiClient, dataset: Dataset): Promise {
+ for (const visit of dataset.visits) {
+ let cache: string | undefined;
+
+ for (const event of visit.events) {
+ cache = (await send(api, event, cache)).cache;
+ }
+ }
+
+ for (const items of chunk(dataset.batch, BATCH_LIMIT)) {
+ const response = await api.post('/api/batch', items);
+
+ if (response.status !== 200) {
+ throw new Error(`POST /api/batch failed (${response.status}): ${response.text}`);
+ }
+
+ if (response.body?.errors) {
+ throw new Error(
+ `POST /api/batch reported ${response.body.errors} error(s): ${JSON.stringify(
+ response.body.details?.slice(0, 3),
+ )}`,
+ );
+ }
+ }
+
+ const live = await send(api, dataset.replay.pageview);
+
+ await record(api, dataset.replay.record, live.cache);
+ await record(api, dataset.replay.heatmap, live.cache);
+
+ for (const event of dataset.realtime) {
+ await send(api, event);
+ }
+
+ return { replay: { sessionId: live.sessionId, visitId: live.visitId } };
+}
diff --git a/tests/api/seed/setup.ts b/tests/api/seed/setup.ts
new file mode 100644
index 000000000..f95f7ac1a
--- /dev/null
+++ b/tests/api/seed/setup.ts
@@ -0,0 +1,262 @@
+import type { ApiClient } from '../client';
+import { login } from '../helpers/auth';
+import { ADMIN_USER } from '../helpers/constants';
+import { assertStatus } from '../helpers/entities';
+import { buildDataset, CURRENCY, type Dataset, HOSTNAME } from './dataset';
+import {
+ SEED_IDS,
+ SEED_LINK,
+ SEED_PIXEL,
+ SEED_SHARE,
+ SEED_TEAM,
+ SEED_USERS,
+ SEED_WEBSITES,
+} from './ids';
+import { type IngestResult, ingestDataset } from './ingest';
+import type { SeedCredentials, SeedState } from './state';
+
+const READINESS_TIMEOUT_MS = 60_000;
+
+async function ensureUser(
+ admin: ApiClient,
+ credentials: { username: string; password: string; role: string },
+): Promise {
+ const created = await admin.post('/api/users', credentials);
+
+ if (created.status === 200) {
+ return { ...credentials, id: created.body.id };
+ }
+
+ if (created.status !== 400) {
+ throw new Error(
+ `Failed to create ${credentials.username} (${created.status}): ${created.text}`,
+ );
+ }
+
+ // Already exists (re-run against a kept stack): look it up.
+ const users = assertStatus(
+ await admin.get('/api/admin/users', { params: { search: credentials.username } }),
+ 200,
+ 'list users',
+ );
+ const user = users.body.data?.find((u: any) => u.username === credentials.username);
+
+ if (!user) {
+ throw new Error(`User ${credentials.username} exists but could not be found`);
+ }
+
+ return { ...credentials, id: user.id };
+}
+
+async function recreateWebsite(
+ client: ApiClient,
+ id: string,
+ data: { name: string; domain: string },
+) {
+ const existing = await client.get(`/api/websites/${id}`);
+
+ // Admins bypass the permission check, so an unknown id yields 200 with a null body.
+ if (existing.status === 200 && existing.body?.id) {
+ assertStatus(await client.del(`/api/websites/${id}`), 200, `delete website ${id}`);
+ }
+
+ return assertStatus(await client.post('/api/websites', { id, ...data }), 200, 'create website')
+ .body;
+}
+
+async function ensureEntity(
+ client: ApiClient,
+ collection: 'links' | 'pixels',
+ id: string,
+ data: Record,
+) {
+ const existing = await client.get(`/api/${collection}/${id}`);
+
+ if (existing.status === 200 && existing.body?.id) {
+ return existing.body;
+ }
+
+ return assertStatus(
+ await client.post(`/api/${collection}`, { id, ...data }),
+ 200,
+ `create ${collection}`,
+ ).body;
+}
+
+async function ensureTeam(admin: ApiClient, memberId: string) {
+ const teams = assertStatus(await admin.get('/api/teams'), 200, 'list teams');
+ let team = teams.body.data?.find((t: any) => t.name === SEED_TEAM.name);
+
+ if (!team) {
+ const created = assertStatus(await admin.post('/api/teams', SEED_TEAM), 200, 'create team');
+ team = created.body[0];
+ }
+
+ const detail = assertStatus(await admin.get(`/api/teams/${team.id}`), 200, 'get team').body;
+ const member = await admin.post(`/api/teams/${team.id}/users`, {
+ userId: memberId,
+ role: 'team-member',
+ });
+
+ // 400 = already a member (re-run).
+ if (member.status !== 200 && member.status !== 400) {
+ throw new Error(`Failed to add team member (${member.status}): ${member.text}`);
+ }
+
+ return {
+ id: detail.id as string,
+ name: detail.name as string,
+ accessCode: detail.accessCode as string,
+ };
+}
+
+async function poll(label: string, fn: () => Promise, timeoutMs: number) {
+ const deadline = Date.now() + timeoutMs;
+ let last: string | undefined;
+
+ while (Date.now() < deadline) {
+ try {
+ const result = await fn();
+
+ if (result !== undefined) {
+ return result;
+ }
+ } catch (error) {
+ last = (error as Error).message;
+ }
+
+ await new Promise(resolve => setTimeout(resolve, 500));
+ }
+
+ throw new Error(`Timed out waiting for ${label}${last ? ` (${last})` : ''}`);
+}
+
+/**
+ * Waits until the analytics queries see the ingested data (ClickHouse
+ * materialized views are synchronous, but this guards against any lag) and
+ * harvests ids that specs need for path parameters.
+ */
+async function waitForAnalytics(admin: ApiClient, dataset: Dataset, ingested: IngestResult) {
+ const websiteId = SEED_IDS.website;
+ const range = () => ({ startAt: dataset.range.startAt, endAt: Date.now() });
+
+ await poll(
+ `${dataset.expected.pageviews} pageviews on ${websiteId}`,
+ async () => {
+ const response = await admin.get(`/api/websites/${websiteId}/stats`, { params: range() });
+ // /stats returns flat numbers (possibly as strings under Postgres bigint).
+ const pageviews = Number(response.body?.pageviews ?? 0);
+
+ return pageviews >= dataset.expected.pageviews ? pageviews : undefined;
+ },
+ READINESS_TIMEOUT_MS,
+ );
+
+ const sessionId = await poll(
+ 'a session',
+ async () => {
+ const response = await admin.get(`/api/websites/${websiteId}/sessions`, {
+ params: { ...range(), pageSize: 1 },
+ });
+
+ return response.body?.data?.[0]?.id as string | undefined;
+ },
+ READINESS_TIMEOUT_MS,
+ );
+
+ const eventId = await poll(
+ 'a custom event with data',
+ async () => {
+ const response = await admin.get(`/api/websites/${websiteId}/events`, {
+ params: { ...range(), pageSize: 50, eventType: 2 },
+ });
+ const event = response.body?.data?.find((e: any) => e.hasData);
+
+ return event?.id as string | undefined;
+ },
+ READINESS_TIMEOUT_MS,
+ );
+
+ await poll(
+ 'the recorded replay',
+ async () => {
+ const response = await admin.get(`/api/websites/${websiteId}/replays`, {
+ params: range(),
+ });
+
+ return response.body?.data?.some((r: any) => r.id === ingested.replay.visitId)
+ ? true
+ : undefined;
+ },
+ READINESS_TIMEOUT_MS,
+ );
+
+ return { sessionId, eventId };
+}
+
+export async function seedEnvironment(api: ApiClient): Promise {
+ const db = process.env.UMAMI_TEST_DB === 'clickhouse' ? 'clickhouse' : 'postgres';
+
+ const admin = api.bearer(await login(api, ADMIN_USER));
+ const user = await ensureUser(admin, SEED_USERS.user);
+ const viewer = await ensureUser(admin, SEED_USERS.viewer);
+ const userApi = api.bearer(await login(api, SEED_USERS.user));
+
+ const website = await recreateWebsite(admin, SEED_IDS.website, SEED_WEBSITES.primary);
+ const website2 = await recreateWebsite(userApi, SEED_IDS.website2, SEED_WEBSITES.secondary);
+
+ assertStatus(
+ await admin.post(`/api/websites/${website.id}`, {
+ replayConfig: {
+ replayEnabled: true,
+ heatmapEnabled: true,
+ sampleRate: 1,
+ heatmapSampleRate: 1,
+ },
+ }),
+ 200,
+ 'enable recorder',
+ );
+
+ const share = assertStatus(
+ await admin.post(`/api/websites/${website.id}/shares`, { ...SEED_SHARE, parameters: {} }),
+ 200,
+ 'create share',
+ ).body;
+
+ const link = await ensureEntity(admin, 'links', SEED_IDS.link, SEED_LINK);
+ const pixel = await ensureEntity(admin, 'pixels', SEED_IDS.pixel, SEED_PIXEL);
+ const team = await ensureTeam(admin, user.id);
+
+ const dataset = buildDataset();
+ const ingested = await ingestDataset(api, dataset);
+ const { sessionId, eventId } = await waitForAnalytics(admin, dataset, ingested);
+
+ return {
+ db,
+ createdAt: new Date().toISOString(),
+ admin: { ...ADMIN_USER },
+ user,
+ viewer,
+ website: { id: website.id, name: website.name, domain: website.domain },
+ website2: { id: website2.id, name: website2.name, domain: website2.domain },
+ link: { id: link.id, name: link.name, url: link.url, slug: link.slug },
+ pixel: { id: pixel.id, name: pixel.name, slug: pixel.slug },
+ team,
+ share: { id: share.id, slug: share.slug },
+ range: dataset.range,
+ data: {
+ hostname: HOSTNAME,
+ currency: CURRENCY,
+ pages: dataset.pages,
+ eventNames: dataset.eventNames,
+ distinctIds: dataset.distinctIds,
+ expectedPageviews: dataset.expected.pageviews,
+ expectedWebsite2Pageviews: dataset.expected.website2Pageviews,
+ sessionId,
+ eventId,
+ replayVisitId: ingested.replay.visitId,
+ replaySessionId: ingested.replay.sessionId,
+ },
+ };
+}
diff --git a/tests/api/seed/state.ts b/tests/api/seed/state.ts
new file mode 100644
index 000000000..eb52ab955
--- /dev/null
+++ b/tests/api/seed/state.ts
@@ -0,0 +1,49 @@
+import { readFileSync } from 'node:fs';
+import { SEED_FILE } from '../paths';
+
+export interface SeedCredentials {
+ id: string;
+ username: string;
+ password: string;
+ role: string;
+}
+
+/** Written by global setup to tests/api/.runtime/seed.json and read by the fixtures. */
+export interface SeedState {
+ db: 'postgres' | 'clickhouse';
+ createdAt: string;
+ admin: SeedCredentials;
+ user: SeedCredentials;
+ viewer: SeedCredentials;
+ /** Admin-owned website carrying the full analytics dataset. */
+ website: { id: string; name: string; domain: string };
+ /** Website owned by `user` with a small dataset. */
+ website2: { id: string; name: string; domain: string };
+ link: { id: string; name: string; url: string; slug: string };
+ pixel: { id: string; name: string; slug: string };
+ team: { id: string; name: string; accessCode: string };
+ /** Share for `website` granting every section. */
+ share: { id: string; slug: string };
+ /** Dataset window in ms. */
+ range: { startAt: number; endAt: number };
+ data: {
+ hostname: string;
+ currency: string;
+ pages: string[];
+ eventNames: string[];
+ distinctIds: string[];
+ expectedPageviews: number;
+ expectedWebsite2Pageviews: number;
+ /** A session id from `website` (used for /sessions/{sessionId} endpoints). */
+ sessionId: string;
+ /** A custom event id with event data (used for /event-data/{eventId}). */
+ eventId: string;
+ /** Visit id of the recorded replay (replay ids are visit ids). */
+ replayVisitId: string;
+ replaySessionId: string;
+ };
+}
+
+export function readSeedState(): SeedState {
+ return JSON.parse(readFileSync(SEED_FILE, 'utf8'));
+}
diff --git a/tests/api/segments.spec.ts b/tests/api/segments.spec.ts
new file mode 100644
index 000000000..d34e9c235
--- /dev/null
+++ b/tests/api/segments.spec.ts
@@ -0,0 +1,209 @@
+import { expect, test } from './fixtures';
+import { UNKNOWN_UUID } from './helpers/constants';
+import { dateRange } from './helpers/dates';
+import { createWebsite, deleteWebsite, uniqueName } from './helpers/entities';
+
+test.describe('Segments', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ const parameters = {
+ filters: [{ name: 'path', operator: 'eq', value: '/pricing' }],
+ match: 'all',
+ };
+
+ let websiteId = '';
+ let segmentId = '';
+ let cohortId = '';
+ let seedSegmentId = '';
+ let base = '';
+
+ test.beforeAll(async ({ admin }) => {
+ websiteId = (await createWebsite(admin)).id;
+ base = `/api/websites/${websiteId}/segments`;
+ });
+
+ test.afterAll(async ({ admin, seed }) => {
+ if (seedSegmentId) {
+ await admin.del(`/api/websites/${seed.website.id}/segments/${seedSegmentId}`);
+ }
+
+ if (websiteId) {
+ await deleteWebsite(admin, websiteId);
+ }
+ });
+
+ test('POST /api/websites/{websiteId}/segments creates a segment', async ({ admin }) => {
+ const name = uniqueName('segment');
+ const response = await admin.post(base, { type: 'segment', name, parameters });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ id: expect.any(String),
+ websiteId,
+ type: 'segment',
+ name,
+ parameters,
+ createdAt: expect.any(String),
+ });
+
+ segmentId = response.body.id;
+ });
+
+ test('POST /api/websites/{websiteId}/segments creates a cohort', async ({ admin }) => {
+ const name = uniqueName('cohort');
+ const response = await admin.post(base, {
+ type: 'cohort',
+ name,
+ parameters: { ...parameters, dateRange: '30d', action: { type: 'event', value: 'signup' } },
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ websiteId, type: 'cohort', name });
+
+ cohortId = response.body.id;
+ });
+
+ test('POST /api/websites/{websiteId}/segments validates and requires permission', async ({
+ admin,
+ user,
+ viewer,
+ api,
+ }) => {
+ const badType = await admin.post(base, { type: 'nope', name: 'x', parameters });
+ const missingParams = await admin.post(base, { type: 'segment', name: 'x' });
+ const badOperator = await admin.post(base, {
+ type: 'segment',
+ name: 'x',
+ parameters: { filters: [{ name: 'path', operator: 'nope', value: '/' }] },
+ });
+ const tooLong = await admin.post(base, { type: 'segment', name: 'x'.repeat(201), parameters });
+ const notOwner = await user.post(base, { type: 'segment', name: 'x', parameters });
+ const denied = await viewer.post(base, { type: 'segment', name: 'x', parameters });
+ const anonymous = await api.post(base, { type: 'segment', name: 'x', parameters });
+
+ expect(badType.status).toBe(400);
+ expect(missingParams.status).toBe(400);
+ expect(badOperator.status).toBe(400);
+ expect(tooLong.status).toBe(400);
+ expect(notOwner.status).toBe(401);
+ expect(denied.status).toBe(401);
+ expect(anonymous.status).toBe(401);
+ });
+
+ test('GET /api/websites/{websiteId}/segments lists segments by type', async ({
+ admin,
+ viewer,
+ share,
+ seed,
+ }) => {
+ const segments = await admin.get(base, { params: { type: 'segment' } });
+ const cohorts = await admin.get(base, { params: { type: 'cohort' } });
+ const search = await admin.get(base, {
+ params: { type: 'segment', search: 'zzz-no-such-segment' },
+ });
+ const missingType = await admin.get(base);
+ const badType = await admin.get(base, { params: { type: 'nope' } });
+ const denied = await viewer.get(base, { params: { type: 'segment' } });
+ const shared = await (await share()).get(`/api/websites/${seed.website.id}/segments`, {
+ params: { type: 'segment' },
+ });
+
+ expect(segments.status).toBe(200);
+ expect(segments.body).toMatchObject({
+ data: expect.any(Array),
+ count: 1,
+ page: 1,
+ pageSize: expect.any(Number),
+ });
+ expect(segments.body.data[0]).toMatchObject({ id: segmentId, type: 'segment', parameters });
+ expect(cohorts.body.data.map((s: any) => s.id)).toEqual([cohortId]);
+ expect(search.body.data).toHaveLength(0);
+ expect(missingType.status).toBe(400);
+ expect(badType.status).toBe(400);
+ expect(denied.status).toBe(401);
+ expect(shared.status).toBe(200);
+ });
+
+ test('GET /api/websites/{websiteId}/segments/{segmentId} returns a segment', async ({
+ admin,
+ viewer,
+ }) => {
+ const response = await admin.get(`${base}/${segmentId}`);
+ const denied = await viewer.get(`${base}/${segmentId}`);
+ const unknown = await admin.get(`${base}/${UNKNOWN_UUID}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ id: segmentId, websiteId, type: 'segment', parameters });
+ expect(denied.status).toBe(401);
+ expect(unknown.status).toBe(404);
+ });
+
+ test('POST /api/websites/{websiteId}/segments/{segmentId} updates a segment', async ({
+ admin,
+ user,
+ }) => {
+ const name = uniqueName('renamed');
+ const updated = { filters: [{ name: 'path', operator: 'c', value: '/docs' }], match: 'any' };
+ const response = await admin.post(`${base}/${segmentId}`, {
+ type: 'segment',
+ name,
+ parameters: updated,
+ });
+ const invalid = await admin.post(`${base}/${segmentId}`, { name });
+ const unknown = await admin.post(`${base}/${UNKNOWN_UUID}`, {
+ type: 'segment',
+ name,
+ parameters: updated,
+ });
+ const denied = await user.post(`${base}/${segmentId}`, {
+ type: 'segment',
+ name,
+ parameters: updated,
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ id: segmentId, name, parameters: updated });
+ expect(invalid.status).toBe(400);
+ expect(unknown.status).toBe(404);
+ expect(denied.status).toBe(401);
+ });
+
+ test('GET /api/websites/{websiteId}/stats accepts a segment filter', async ({ admin, seed }) => {
+ const created = await admin.post(`/api/websites/${seed.website.id}/segments`, {
+ type: 'segment',
+ name: uniqueName('segment'),
+ parameters,
+ });
+
+ expect(created.status).toBe(200);
+ seedSegmentId = created.body.id;
+
+ const stats = await admin.get(`/api/websites/${seed.website.id}/stats`, {
+ params: dateRange(seed, { segment: seedSegmentId }),
+ });
+
+ expect(stats.status).toBe(200);
+ expect(stats.body).toMatchObject({ pageviews: expect.anything(), visitors: expect.anything() });
+ });
+
+ test('DELETE /api/websites/{websiteId}/segments/{segmentId} deletes a segment', async ({
+ admin,
+ user,
+ }) => {
+ const denied = await user.del(`${base}/${segmentId}`);
+ const response = await admin.del(`${base}/${segmentId}`);
+ const cohort = await admin.del(`${base}/${cohortId}`);
+ const gone = await admin.get(`${base}/${segmentId}`);
+ const unknown = await admin.del(`${base}/${UNKNOWN_UUID}`);
+
+ expect(denied.status).toBe(401);
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+ expect(cohort.status).toBe(200);
+ expect(gone.status).toBe(404);
+ expect(unknown.status).toBe(404);
+
+ segmentId = '';
+ cohortId = '';
+ });
+});
diff --git a/tests/api/session-data.spec.ts b/tests/api/session-data.spec.ts
new file mode 100644
index 000000000..3289dc8af
--- /dev/null
+++ b/tests/api/session-data.spec.ts
@@ -0,0 +1,381 @@
+import { expect, test } from './fixtures';
+import { dateRange, TIMEZONE } from './helpers/dates';
+
+/**
+ * Seeded session data on `seed.website` comes from the `identify` calls of the
+ * three identified personas (see seed/dataset.ts):
+ * { plan: 'pro', seats: 5 | 6 | 7, tags: ['early-adopter','newsletter'], signedUpAt, active: true }
+ * Other specs may identify extra sessions concurrently (e.g. plan 'spec'), so
+ * list assertions use `toContain`; `seats`/`tags`/`signedUpAt` are only seeded here
+ * and are asserted exactly.
+ */
+
+const SEATS = [5, 6, 7];
+const SEATS_TOTAL = SEATS.reduce((sum, value) => sum + value, 0);
+
+test.describe('Session data', () => {
+ let base = '';
+
+ test.beforeAll(({ seed }) => {
+ base = `/api/websites/${seed.website.id}`;
+ });
+
+ test('GET /api/websites/{websiteId}/session-data-pivot pivots session properties into columns', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const response = await admin.get(`${base}/session-data-pivot`, {
+ params: dateRange(seed, {
+ propertyName: 'seats',
+ timezone: TIMEZONE,
+ unit: 'day',
+ pageSize: 100,
+ }),
+ });
+ const paged = await admin.get(`${base}/session-data-pivot`, {
+ params: dateRange(seed, { propertyName: 'seats', page: 2, pageSize: 2 }),
+ });
+ const propertyFiltered = await admin.get(`${base}/session-data-pivot`, {
+ params: dateRange(seed, { propertyName: 'seats', pf_seats: '2.gt.5', pageSize: 100 }),
+ });
+ const shared = await (await share()).get(`${base}/session-data-pivot`, {
+ params: dateRange(seed, { propertyName: 'plan' }),
+ });
+ const denied = await viewer.get(`${base}/session-data-pivot`, {
+ params: dateRange(seed, { propertyName: 'plan' }),
+ });
+ const missingProperty = await admin.get(`${base}/session-data-pivot`, {
+ params: dateRange(seed),
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ count: 3, page: 1, pageSize: 100 });
+ expect(response.body.data).toHaveLength(3);
+ for (const row of response.body.data) {
+ expect(row).toMatchObject({
+ sessionId: expect.any(String),
+ distinctId: expect.any(String),
+ 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([
+ 'active',
+ 'plan',
+ 'seats',
+ 'signedUpAt',
+ 'tags',
+ ]);
+ expect(row.propertyValues).toHaveLength(5);
+ expect(seed.data.distinctIds).toContain(row.distinctId);
+ expect(row.propertyValues[row.propertyKeys.indexOf('plan')]).toBe('pro');
+ expect(SEATS).toContain(Number(row.propertyValues[row.propertyKeys.indexOf('seats')]));
+ }
+
+ expect(paged.status).toBe(200);
+ expect(paged.body).toMatchObject({ count: 3, page: 2, pageSize: 2 });
+ expect(paged.body.data).toHaveLength(1);
+
+ // seats > 5 leaves bob (6) and carol (7).
+ expect(propertyFiltered.status).toBe(200);
+ expect(propertyFiltered.body.count).toBe(2);
+ expect(
+ propertyFiltered.body.data
+ .map((row: any) => Number(row.propertyValues[row.propertyKeys.indexOf('seats')]))
+ .sort(),
+ ).toEqual([6, 7]);
+
+ expect(shared.status).toBe(200);
+ expect(denied.status).toBe(401);
+ expect(missingProperty.status).toBe(400);
+ });
+
+ test('GET /api/websites/{websiteId}/session-data/array-series expands array properties over time', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const params = dateRange(seed, { propertyName: 'tags' });
+ const response = await admin.get(`${base}/session-data/array-series`, { params });
+ const shared = await (await share()).get(`${base}/session-data/array-series`, { params });
+ const denied = await viewer.get(`${base}/session-data/array-series`, { params });
+ const missingProperty = await admin.get(`${base}/session-data/array-series`, {
+ params: dateRange(seed),
+ });
+
+ 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(['early-adopter', 'newsletter']),
+ );
+ for (const tag of ['early-adopter', 'newsletter']) {
+ const total = response.body
+ .filter((point: any) => point.x === tag)
+ .reduce((sum: number, point: any) => sum + point.y, 0);
+ expect(total).toBe(3);
+ }
+
+ expect(shared.status).toBe(200);
+ expect(denied.status).toBe(401);
+ expect(missingProperty.status).toBe(400);
+ });
+
+ test('GET /api/websites/{websiteId}/session-data/date-series counts date properties', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const params = dateRange(seed, { propertyName: 'signedUpAt' });
+ const response = await admin.get(`${base}/session-data/date-series`, { params });
+ const shared = await (await share()).get(`${base}/session-data/date-series`, { params });
+ const denied = await viewer.get(`${base}/session-data/date-series`, { params });
+ const missingProperty = await admin.get(`${base}/session-data/date-series`, {
+ params: dateRange(seed),
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toHaveLength(3);
+ 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(missingProperty.status).toBe(400);
+ });
+
+ test('GET /api/websites/{websiteId}/session-data/numeric-series aggregates numeric properties', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const params = dateRange(seed, { propertyName: 'seats', unit: 'day', timezone: TIMEZONE });
+ const sum = await admin.get(`${base}/session-data/numeric-series`, { params });
+ const avg = await admin.get(`${base}/session-data/numeric-series`, {
+ params: { ...params, metric: 'avg' },
+ });
+ const count = await admin.get(`${base}/session-data/numeric-series`, {
+ params: { ...params, metric: 'count' },
+ });
+ const shared = await (await share()).get(`${base}/session-data/numeric-series`, { params });
+ const denied = await viewer.get(`${base}/session-data/numeric-series`, { params });
+ const badMetric = await admin.get(`${base}/session-data/numeric-series`, {
+ params: { ...params, metric: 'max' },
+ });
+ const missingProperty = await admin.get(`${base}/session-data/numeric-series`, {
+ params: dateRange(seed),
+ });
+
+ const total = (rows: any[]) => rows.reduce((acc, row) => acc + Number(row.y), 0);
+
+ // All three identify calls land on the same (backdated) day.
+ expect(sum.status).toBe(200);
+ expect(sum.body.length).toBeGreaterThan(0);
+ for (const point of sum.body) {
+ expect(point).toEqual({ t: expect.any(String), y: expect.anything() });
+ }
+ expect(total(sum.body)).toBe(SEATS_TOTAL);
+
+ expect(avg.status).toBe(200);
+ expect(total(avg.body) / avg.body.length).toBeCloseTo(6, 5);
+
+ expect(count.status).toBe(200);
+ expect(total(count.body)).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}/session-data/numeric-stats summarises a numeric property', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const params = dateRange(seed, { propertyName: 'seats' });
+ const response = await admin.get(`${base}/session-data/numeric-stats`, { params });
+ const shared = await (await share()).get(`${base}/session-data/numeric-stats`, { params });
+ const denied = await viewer.get(`${base}/session-data/numeric-stats`, { params });
+ const missingProperty = await admin.get(`${base}/session-data/numeric-stats`, {
+ params: dateRange(seed),
+ });
+
+ expect(response.status).toBe(200);
+ expect(Object.keys(response.body).sort()).toEqual(['average', 'max', 'median', 'min', 'total']);
+ expect(Number(response.body.min)).toBe(5);
+ expect(Number(response.body.max)).toBe(7);
+ expect(Number(response.body.average)).toBeCloseTo(6, 5);
+ expect(Number(response.body.median)).toBeCloseTo(6, 5);
+ expect(Number(response.body.total)).toBe(SEATS_TOTAL);
+
+ expect(shared.status).toBe(200);
+ expect(denied.status).toBe(401);
+ expect(missingProperty.status).toBe(400);
+ });
+
+ test('GET /api/websites/{websiteId}/session-data/property-series counts property values over time', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const params = dateRange(seed, { propertyName: 'plan', unit: 'day', timezone: TIMEZONE });
+ const response = await admin.get(`${base}/session-data/property-series`, { params });
+ const shared = await (await share()).get(`${base}/session-data/property-series`, { params });
+ const denied = await viewer.get(`${base}/session-data/property-series`, { params });
+ const missingProperty = await admin.get(`${base}/session-data/property-series`, {
+ params: dateRange(seed),
+ });
+
+ expect(response.status).toBe(200);
+ for (const point of response.body) {
+ expect(point).toEqual({
+ x: expect.any(String),
+ t: expect.any(String),
+ y: expect.any(Number),
+ });
+ }
+ const pro = response.body
+ .filter((point: any) => point.x === 'pro')
+ .reduce((sum: number, point: any) => sum + point.y, 0);
+ expect(pro).toBe(3);
+
+ expect(shared.status).toBe(200);
+ expect(denied.status).toBe(401);
+ expect(missingProperty.status).toBe(400);
+ });
+
+ test('GET /api/websites/{websiteId}/session-data/stats aggregates activity per property value', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const params = dateRange(seed, { propertyName: 'plan' });
+ const response = await admin.get(`${base}/session-data/stats`, { params });
+ const shared = await (await share()).get(`${base}/session-data/stats`, { params });
+ const denied = await viewer.get(`${base}/session-data/stats`, { params });
+ const missingProperty = await admin.get(`${base}/session-data/stats`, {
+ params: dateRange(seed),
+ });
+
+ expect(response.status).toBe(200);
+ for (const row of response.body) {
+ expect(Object.keys(row).sort()).toEqual([
+ 'activity',
+ 'events',
+ 'label',
+ 'sessions',
+ 'views',
+ 'visits',
+ ]);
+ }
+ const pro = response.body.find((row: any) => row.label === 'pro');
+ expect(pro).toBeDefined();
+ expect(Number(pro.sessions)).toBe(3);
+ expect(Number(pro.views)).toBeGreaterThan(0);
+
+ expect(shared.status).toBe(200);
+ expect(denied.status).toBe(401);
+ expect(missingProperty.status).toBe(400);
+ });
+
+ test('GET /api/websites/{websiteId}/session-data/properties lists session properties', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const response = await admin.get(`${base}/session-data/properties`, {
+ params: dateRange(seed),
+ });
+ const single = await admin.get(`${base}/session-data/properties`, {
+ params: dateRange(seed, { propertyName: 'seats' }),
+ });
+ const shared = await (await share()).get(`${base}/session-data/properties`, {
+ params: dateRange(seed),
+ });
+ const denied = await viewer.get(`${base}/session-data/properties`, {
+ params: dateRange(seed),
+ });
+ const missingRange = await admin.get(`${base}/session-data/properties`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual(
+ expect.arrayContaining([
+ { propertyName: 'seats', dataType: 2, total: 3 },
+ { propertyName: 'active', dataType: 3, total: 3 },
+ { propertyName: 'signedUpAt', dataType: 4, total: 3 },
+ { propertyName: 'tags', dataType: 5, total: 3 },
+ expect.objectContaining({ propertyName: 'plan', dataType: 1 }),
+ ]),
+ );
+
+ // `propertyName` scopes to sessions that carry that property, then lists every
+ // property of those sessions (co-occurrence), so `plan` is limited to the 3 seeded ones.
+ expect(single.status).toBe(200);
+ expect(single.body).toHaveLength(5);
+ expect(single.body).toEqual(
+ expect.arrayContaining([
+ { propertyName: 'seats', dataType: 2, total: 3 },
+ { propertyName: 'plan', dataType: 1, total: 3 },
+ ]),
+ );
+
+ expect(shared.status).toBe(200);
+ expect(denied.status).toBe(401);
+ expect(missingRange.status).toBe(400);
+ });
+
+ test('GET /api/websites/{websiteId}/session-data/values returns value counts for a property', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const plan = await admin.get(`${base}/session-data/values`, {
+ params: dateRange(seed, { propertyName: 'plan' }),
+ });
+ const seats = await admin.get(`${base}/session-data/values`, {
+ params: dateRange(seed, { propertyName: 'seats', dataType: 2 }),
+ });
+ const shared = await (await share()).get(`${base}/session-data/values`, {
+ params: dateRange(seed, { propertyName: 'plan' }),
+ });
+ const denied = await viewer.get(`${base}/session-data/values`, {
+ params: dateRange(seed, { propertyName: 'plan' }),
+ });
+ const missingRange = await admin.get(`${base}/session-data/values`, {
+ params: { propertyName: 'plan' },
+ });
+
+ expect(plan.status).toBe(200);
+ for (const row of plan.body) {
+ expect(row).toEqual({ value: expect.any(String), total: expect.any(Number) });
+ }
+ expect(plan.body.map((row: any) => row.value)).toContain('pro');
+
+ expect(seats.status).toBe(200);
+ expect(seats.body.map((row: any) => Number(row.value)).sort()).toEqual(SEATS);
+
+ expect(shared.status).toBe(200);
+ expect(denied.status).toBe(401);
+ expect(missingRange.status).toBe(400);
+ });
+});
diff --git a/tests/api/sessions.spec.ts b/tests/api/sessions.spec.ts
new file mode 100644
index 000000000..1ca741dd7
--- /dev/null
+++ b/tests/api/sessions.spec.ts
@@ -0,0 +1,334 @@
+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);
+ });
+ });
+});
diff --git a/tests/api/shares.spec.ts b/tests/api/shares.spec.ts
new file mode 100644
index 000000000..e2619e6c2
--- /dev/null
+++ b/tests/api/shares.spec.ts
@@ -0,0 +1,270 @@
+import { expect, test } from './fixtures';
+import { ENTITY_TYPE, UNKNOWN_UUID } from './helpers/constants';
+import { dateRange } from './helpers/dates';
+import { createWebsite, deleteWebsite, uniqueName, uniqueSlug } from './helpers/entities';
+
+test.describe('Shares', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let websiteId = '';
+ let shareId = '';
+ let slug = '';
+ let websiteShareId = '';
+ let gatedShareId = '';
+ let gatedSlug = '';
+
+ test.beforeAll(async ({ admin }) => {
+ websiteId = (await createWebsite(admin)).id;
+ });
+
+ test.afterAll(async ({ admin }) => {
+ for (const id of [shareId, websiteShareId, gatedShareId]) {
+ if (id) {
+ await admin.del(`/api/share/id/${id}`);
+ }
+ }
+
+ if (websiteId) {
+ await deleteWebsite(admin, websiteId);
+ }
+ });
+
+ test('POST /api/share creates a share for an entity', async ({ admin }) => {
+ const name = uniqueName('share');
+ slug = uniqueSlug('share');
+ const response = await admin.post('/api/share', {
+ entityId: websiteId,
+ shareType: ENTITY_TYPE.website,
+ name,
+ slug,
+ parameters: { overview: true },
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ id: expect.any(String),
+ entityId: websiteId,
+ shareType: ENTITY_TYPE.website,
+ name,
+ slug,
+ parameters: { overview: true },
+ createdAt: expect.any(String),
+ });
+
+ shareId = response.body.id;
+ });
+
+ test('POST /api/share generates a slug when omitted', async ({ admin }) => {
+ const response = await admin.post('/api/share', {
+ entityId: websiteId,
+ shareType: ENTITY_TYPE.website,
+ name: uniqueName('share'),
+ parameters: {},
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.slug).toMatch(/^[A-Za-z0-9]{16}$/);
+
+ await admin.del(`/api/share/id/${response.body.id}`);
+ });
+
+ test('POST /api/share validates the body and checks entity access', async ({
+ admin,
+ user,
+ viewer,
+ api,
+ }) => {
+ const missing = await admin.post('/api/share', { entityId: websiteId, name: 'x' });
+ const badId = await admin.post('/api/share', {
+ entityId: 'nope',
+ shareType: ENTITY_TYPE.website,
+ name: 'x',
+ parameters: {},
+ });
+ // Non-admin callers cannot share an entity that does not exist (or is not theirs).
+ const unknown = await user.post('/api/share', {
+ entityId: UNKNOWN_UUID,
+ shareType: ENTITY_TYPE.website,
+ name: 'x',
+ parameters: {},
+ });
+ const denied = await viewer.post('/api/share', {
+ entityId: websiteId,
+ shareType: ENTITY_TYPE.website,
+ name: 'x',
+ parameters: {},
+ });
+ const anonymous = await api.post('/api/share', {
+ entityId: websiteId,
+ shareType: ENTITY_TYPE.website,
+ name: 'x',
+ parameters: {},
+ });
+
+ expect(missing.status).toBe(400);
+ expect(badId.status).toBe(400);
+ expect(unknown.status).toBe(401);
+ expect(denied.status).toBe(401);
+ expect(anonymous.status).toBe(401);
+ });
+
+ test('GET /api/share/{slug} publicly resolves a share to a token', async ({ api, seed }) => {
+ const response = await api.get(`/api/share/${slug}`);
+ const seeded = await api.get(`/api/share/${seed.share.slug}`);
+ const unknown = await api.get('/api/share/no-such-slug');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ shareId,
+ shareType: ENTITY_TYPE.website,
+ websiteId,
+ parameters: { overview: true },
+ token: expect.any(String),
+ });
+ expect(seeded.status).toBe(200);
+ expect(seeded.body).toMatchObject({ shareId: seed.share.id, websiteId: seed.website.id });
+ expect(unknown.status).toBe(404);
+ });
+
+ test('GET /api/share/id/{shareId} returns a share', async ({ admin, viewer, api }) => {
+ const response = await admin.get(`/api/share/id/${shareId}`);
+ const denied = await viewer.get(`/api/share/id/${shareId}`);
+ const anonymous = await api.get(`/api/share/id/${shareId}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ id: shareId, entityId: websiteId, slug });
+ expect(denied.status).toBe(401);
+ expect(anonymous.status).toBe(401);
+ });
+
+ test('POST /api/share/id/{shareId} updates a share', async ({ admin, viewer }) => {
+ const name = uniqueName('renamed');
+ slug = uniqueSlug('renamed');
+ const response = await admin.post(`/api/share/id/${shareId}`, {
+ name,
+ slug,
+ parameters: { overview: true, events: true },
+ });
+ const partial = await admin.post(`/api/share/id/${shareId}`, { name });
+ const unknown = await admin.post(`/api/share/id/${UNKNOWN_UUID}`, {
+ name,
+ slug: uniqueSlug('x'),
+ parameters: {},
+ });
+ const denied = await viewer.post(`/api/share/id/${shareId}`, {
+ name,
+ slug,
+ parameters: {},
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ id: shareId,
+ name,
+ slug,
+ parameters: { overview: true, events: true },
+ });
+ expect(partial.status).toBe(400);
+ expect(unknown.status).toBe(404);
+ expect(denied.status).toBe(401);
+ });
+
+ test('GET /api/websites/{websiteId}/shares lists website shares', async ({
+ admin,
+ viewer,
+ share,
+ seed,
+ }) => {
+ const response = await admin.get(`/api/websites/${websiteId}/shares`);
+ const paged = await admin.get(`/api/websites/${websiteId}/shares`, {
+ params: { page: 1, pageSize: 1 },
+ });
+ const denied = await viewer.get(`/api/websites/${websiteId}/shares`);
+ const shared = await (await share()).get(`/api/websites/${seed.website.id}/shares`);
+
+ 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((s: any) => s.id)).toContain(shareId);
+ expect(paged.status).toBe(200);
+ expect(paged.body.data).toHaveLength(1);
+ expect(paged.body.pageSize).toBe(1);
+ expect(denied.status).toBe(401);
+ expect(shared.status).toBe(401);
+ });
+
+ test('POST /api/websites/{websiteId}/shares creates a website share', async ({ admin, user }) => {
+ const name = uniqueName('site-share');
+ const response = await admin.post(`/api/websites/${websiteId}/shares`, { name });
+ const invalid = await admin.post(`/api/websites/${websiteId}/shares`, {});
+ const denied = await user.post(`/api/websites/${websiteId}/shares`, { name });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ entityId: websiteId,
+ shareType: ENTITY_TYPE.website,
+ name,
+ slug: expect.any(String),
+ parameters: {},
+ });
+ expect(response.body.slug).toHaveLength(16);
+ expect(invalid.status).toBe(400);
+ expect(denied.status).toBe(401);
+
+ websiteShareId = response.body.id;
+ });
+
+ test('share parameters gate which sections a share token may access', async ({
+ admin,
+ api,
+ share,
+ seed,
+ }) => {
+ gatedSlug = uniqueSlug('gated');
+ const created = await admin.post('/api/share', {
+ entityId: seed.website.id,
+ shareType: ENTITY_TYPE.website,
+ name: uniqueName('gated'),
+ slug: gatedSlug,
+ parameters: { overview: true, events: false },
+ });
+
+ expect(created.status).toBe(200);
+ gatedShareId = created.body.id;
+
+ const resolved = await api.get(`/api/share/${gatedSlug}`);
+
+ expect(resolved.status).toBe(200);
+ expect(resolved.body).toMatchObject({
+ shareId: gatedShareId,
+ websiteId: seed.website.id,
+ parameters: { overview: true, events: false },
+ });
+
+ const client = await share(gatedSlug);
+ const allowed = await client.get(`/api/websites/${seed.website.id}/active`);
+ const denied = await client.get(`/api/websites/${seed.website.id}/events`, {
+ params: dateRange(seed),
+ });
+
+ expect(allowed.status).toBe(200);
+ expect(denied.status).toBe(401);
+ });
+
+ test('DELETE /api/share/id/{shareId} deletes a share', async ({ admin, viewer, api }) => {
+ const denied = await viewer.del(`/api/share/id/${shareId}`);
+ const response = await admin.del(`/api/share/id/${shareId}`);
+ const gone = await api.get(`/api/share/${slug}`);
+
+ expect(denied.status).toBe(401);
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+ expect(gone.status).toBe(404);
+
+ shareId = '';
+ });
+});
diff --git a/tests/api/system.spec.ts b/tests/api/system.spec.ts
new file mode 100644
index 000000000..6fbf2bf49
--- /dev/null
+++ b/tests/api/system.spec.ts
@@ -0,0 +1,32 @@
+import { expect, test } from './fixtures';
+
+test.describe('System', () => {
+ test('GET /api/heartbeat responds without authentication', async ({ api }) => {
+ const response = await api.get('/api/heartbeat');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+ });
+
+ test('GET /api/config reports the runtime feature flags', async ({ api, seed }) => {
+ const response = await api.get('/api/config');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ cloudMode: false,
+ privateMode: false,
+ // Session deletion is only supported when analytics live in Postgres.
+ sessionDeletionEnabled: seed.db === 'postgres',
+ });
+ expect(typeof response.body.telemetryDisabled).toBe('boolean');
+ expect(typeof response.body.updatesDisabled).toBe('boolean');
+ });
+
+ test('GET /api/scripts/telemetry serves a javascript response', async ({ api }) => {
+ const response = await api.get('/api/scripts/telemetry');
+
+ expect(response.status).toBe(200);
+ expect(response.headers['content-type']).toContain('javascript');
+ expect(response.text.length).toBeGreaterThan(0);
+ });
+});
diff --git a/tests/api/teams.spec.ts b/tests/api/teams.spec.ts
new file mode 100644
index 000000000..2f1613fea
--- /dev/null
+++ b/tests/api/teams.spec.ts
@@ -0,0 +1,206 @@
+import { expect, test } from './fixtures';
+import { UNKNOWN_UUID } from './helpers/constants';
+import { createWebsite, deleteWebsite, uniqueDomain, uniqueName } from './helpers/entities';
+
+test.describe('Teams', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let teamId = '';
+ let accessCode = '';
+
+ test.afterAll(async ({ admin }) => {
+ if (teamId) {
+ await admin.del(`/api/teams/${teamId}`);
+ }
+ });
+
+ test('POST /api/teams creates a team owned by the caller', async ({ admin, seed }) => {
+ const name = uniqueName('team');
+ const response = await admin.post('/api/teams', { name });
+
+ expect(response.status).toBe(200);
+ expect(response.body[0]).toMatchObject({ name });
+ expect(response.body[1]).toMatchObject({ userId: seed.admin.id, role: 'team-owner' });
+
+ teamId = response.body[0].id;
+ });
+
+ test('POST /api/teams validates the body and requires authentication', async ({ admin, api }) => {
+ const tooLong = await admin.post('/api/teams', { name: 'x'.repeat(51) });
+ const anonymous = await api.post('/api/teams', { name: 'x' });
+
+ expect(tooLong.status).toBe(400);
+ expect(anonymous.status).toBe(401);
+ });
+
+ test('GET /api/teams lists the caller teams', async ({ admin, viewer }) => {
+ const response = await admin.get('/api/teams');
+ const none = await viewer.get('/api/teams');
+
+ expect(response.status).toBe(200);
+ expect(response.body.data.map((t: any) => t.id)).toContain(teamId);
+ expect(none.status).toBe(200);
+ expect(none.body.data.map((t: any) => t.id)).not.toContain(teamId);
+ });
+
+ test('GET /api/teams/{teamId} returns the team', async ({ admin, viewer }) => {
+ const response = await admin.get(`/api/teams/${teamId}`);
+ const denied = await viewer.get(`/api/teams/${teamId}`);
+ const unknown = await admin.get(`/api/teams/${UNKNOWN_UUID}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ id: teamId, accessCode: expect.any(String) });
+ expect(denied.status).toBe(401);
+ expect(unknown.status).toBe(404);
+
+ accessCode = response.body.accessCode;
+ });
+
+ test('POST /api/teams/{teamId} updates the team', async ({ admin, user }) => {
+ const name = uniqueName('renamed');
+ const newCode = `team_${uniqueName('code')}`;
+ const response = await admin.post(`/api/teams/${teamId}`, { name, accessCode: newCode });
+ const denied = await user.post(`/api/teams/${teamId}`, { name });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ id: teamId, name, accessCode: newCode });
+ expect(denied.status).toBe(401);
+
+ accessCode = newCode;
+ });
+
+ test('POST /api/teams/join adds the caller to a team', async ({ viewer, seed }) => {
+ const response = await viewer.post('/api/teams/join', { accessCode });
+ const again = await viewer.post('/api/teams/join', { accessCode });
+ const unknown = await viewer.post('/api/teams/join', { accessCode: 'team_nope' });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ teamId, userId: seed.viewer.id, role: 'team-member' });
+ expect(again.status).toBe(400);
+ expect(unknown.status).toBe(404);
+ expect(unknown.body.error.code).toBe('team-not-found');
+ });
+
+ test('GET /api/teams/{teamId}/users lists the members', async ({ admin, viewer, user }) => {
+ const response = await admin.get(`/api/teams/${teamId}/users`);
+ const member = await viewer.get(`/api/teams/${teamId}/users`);
+ const denied = await user.get(`/api/teams/${teamId}/users`);
+
+ expect(response.status).toBe(200);
+ expect(response.body.data).toHaveLength(2);
+ expect(response.body.data[0]).toMatchObject({
+ teamId,
+ user: { id: expect.any(String), username: expect.any(String) },
+ });
+ expect(member.status).toBe(200);
+ expect(denied.status).toBe(401);
+ });
+
+ test('GET /api/teams/{teamId}/users/{userId} returns a member', async ({
+ admin,
+ viewer,
+ seed,
+ }) => {
+ const response = await admin.get(`/api/teams/${teamId}/users/${seed.viewer.id}`);
+ const denied = await viewer.get(`/api/teams/${teamId}/users/${seed.viewer.id}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ teamId, userId: seed.viewer.id, role: 'team-member' });
+ // Members that are not owners/managers cannot inspect memberships.
+ expect(denied.status).toBe(401);
+ });
+
+ test('POST /api/teams/{teamId}/users/{userId} updates a member role', async ({
+ admin,
+ viewer,
+ seed,
+ }) => {
+ const response = await admin.post(`/api/teams/${teamId}/users/${seed.viewer.id}`, {
+ role: 'team-view-only',
+ });
+ const notMember = await admin.post(`/api/teams/${teamId}/users/${seed.user.id}`, {
+ role: 'team-member',
+ });
+ const denied = await viewer.post(`/api/teams/${teamId}/users/${seed.viewer.id}`, {
+ role: 'team-manager',
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ userId: seed.viewer.id, role: 'team-view-only' });
+ expect(notMember.status).toBe(400);
+ expect(denied.status).toBe(401);
+ });
+
+ test('DELETE /api/teams/{teamId}/users/{userId} removes a member', async ({ admin, seed }) => {
+ const response = await admin.del(`/api/teams/${teamId}/users/${seed.viewer.id}`);
+ const again = await admin.del(`/api/teams/${teamId}/users/${seed.viewer.id}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+ expect(again.status).toBe(400);
+ });
+
+ test('POST /api/teams/{teamId}/users adds a member', async ({ admin, user, seed }) => {
+ const response = await admin.post(`/api/teams/${teamId}/users`, {
+ userId: seed.user.id,
+ role: 'team-member',
+ });
+ const again = await admin.post(`/api/teams/${teamId}/users`, {
+ userId: seed.user.id,
+ role: 'team-member',
+ });
+ const badRole = await admin.post(`/api/teams/${teamId}/users`, {
+ userId: seed.viewer.id,
+ role: 'team-owner',
+ });
+ const denied = await user.post(`/api/teams/${teamId}/users`, {
+ userId: seed.viewer.id,
+ role: 'team-member',
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ userId: seed.user.id, role: 'team-member' });
+ expect(again.status).toBe(400);
+ expect(badRole.status).toBe(400);
+ expect(denied.status).toBe(401);
+ });
+
+ test('GET /api/teams/{teamId}/websites|boards|links|pixels list team resources', async ({
+ admin,
+ user,
+ viewer,
+ }) => {
+ const website = await createWebsite(admin, { teamId, domain: uniqueDomain() });
+
+ try {
+ const websites = await user.get(`/api/teams/${teamId}/websites`);
+ const boards = await admin.get(`/api/teams/${teamId}/boards`);
+ const links = await admin.get(`/api/teams/${teamId}/links`);
+ const pixels = await admin.get(`/api/teams/${teamId}/pixels`);
+ const denied = await viewer.get(`/api/teams/${teamId}/websites`);
+
+ expect(websites.status).toBe(200);
+ expect(websites.body.data.map((w: any) => w.id)).toContain(website.id);
+
+ for (const response of [boards, links, pixels]) {
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body.data)).toBe(true);
+ }
+
+ expect(denied.status).toBe(401);
+ } finally {
+ await deleteWebsite(admin, website.id);
+ }
+ });
+
+ test('DELETE /api/teams/{teamId} deletes the team', async ({ admin, user }) => {
+ const denied = await user.del(`/api/teams/${teamId}`);
+ const response = await admin.del(`/api/teams/${teamId}`);
+
+ expect(denied.status).toBe(401);
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+
+ teamId = '';
+ });
+});
diff --git a/tests/api/tsconfig.json b/tests/api/tsconfig.json
new file mode 100644
index 000000000..c547e6a93
--- /dev/null
+++ b/tests/api/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "types": ["node"],
+ "rootDir": "../.."
+ },
+ "include": ["./**/*.ts", "../../playwright.api.config.ts", "../../scripts/test-api.ts"],
+ "exclude": [".runtime"]
+}
diff --git a/tests/api/two-factor.spec.ts b/tests/api/two-factor.spec.ts
new file mode 100644
index 000000000..a63d9e376
--- /dev/null
+++ b/tests/api/two-factor.spec.ts
@@ -0,0 +1,348 @@
+import { expect, test } from './fixtures';
+import { login } from './helpers/auth';
+import { createUser, deleteUser } from './helpers/entities';
+import { nextCode, wrongCode } from './helpers/totp';
+
+/**
+ * Full two-factor lifecycle on a throwaway user. TOTP codes are single-use and
+ * bound to 30 s time steps, so several tests wait for the next step; the
+ * describe timeout is raised accordingly.
+ */
+test.describe('Two-factor authentication', () => {
+ test.describe.configure({ mode: 'serial', timeout: 120_000 });
+
+ let userId = '';
+ let username = '';
+ let password = '';
+ /** Regular session token for the throwaway user (issued before 2FA was enabled). */
+ let sessionToken = '';
+ let secret = '';
+ let lastCode = '';
+ let backupCodes: string[] = [];
+ let partialToken = '';
+
+ test.beforeAll(async ({ admin, api }) => {
+ const created = await createUser(admin);
+
+ userId = created.id;
+ username = created.username;
+ password = created.password;
+ sessionToken = await login(api, { username, password });
+ });
+
+ test.afterAll(async ({ admin }) => {
+ if (userId) {
+ // Clear any 2FA state and lockouts before removing the user.
+ await admin.post(`/api/admin/users/${userId}/2fa`, { required: false });
+ await admin.del(`/api/admin/users/${userId}/2fa`);
+ await deleteUser(admin, userId);
+ }
+ });
+
+ test('GET /api/2fa/status reports the initial state', async ({ api, apiKey }) => {
+ const session = api.bearer(sessionToken);
+ const response = await session.get('/api/2fa/status');
+ const anonymous = await api.get('/api/2fa/status');
+ const viaApiKey = await apiKey.get('/api/2fa/status');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ isEnabled: false,
+ isRequired: false,
+ requiredReason: null,
+ isConfigured: true,
+ globalRequired: false,
+ });
+ expect(anonymous.status).toBe(401);
+ // API keys are blocked on /api/2fa/*.
+ expect(viaApiKey.status).toBe(401);
+ });
+
+ test('POST /api/2fa/setup/confirm rejects invalid bodies and missing setups', async ({
+ api,
+ apiKey,
+ }) => {
+ const session = api.bearer(sessionToken);
+ const tooShort = await session.post('/api/2fa/setup/confirm', { token: '123' });
+ const missing = await session.post('/api/2fa/setup/confirm', {});
+ const noSetup = await session.post('/api/2fa/setup/confirm', { token: '000000' });
+ const anonymous = await api.post('/api/2fa/setup/confirm', { token: '000000' });
+ const viaApiKey = await apiKey.post('/api/2fa/setup/confirm', { token: '000000' });
+
+ expect(tooShort.status).toBe(400);
+ expect(missing.status).toBe(400);
+ expect(noSetup.status).toBe(400);
+ expect(noSetup.body.error.code).toBe('two-factor-error-no-pending-setup');
+ expect(anonymous.status).toBe(401);
+ expect(viaApiKey.status).toBe(401);
+ });
+
+ test('POST /api/2fa/setup/initiate returns a secret and QR code', async ({ api, apiKey }) => {
+ const session = api.bearer(sessionToken);
+ const response = await session.post('/api/2fa/setup/initiate');
+ const anonymous = await api.post('/api/2fa/setup/initiate');
+ const viaApiKey = await apiKey.post('/api/2fa/setup/initiate');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ qrCodeDataUrl: expect.stringMatching(/^data:image\/png;base64,/),
+ manualKey: expect.stringMatching(/^[A-Z2-7]+=*$/),
+ });
+ expect(anonymous.status).toBe(401);
+ expect(viaApiKey.status).toBe(401);
+
+ // Status stays disabled until the setup is confirmed.
+ const status = await session.get('/api/2fa/status');
+
+ expect(status.body.isEnabled).toBe(false);
+ });
+
+ test('POST /api/2fa/setup/cancel discards the pending setup', async ({ api, apiKey }) => {
+ const session = api.bearer(sessionToken);
+ const response = await session.post('/api/2fa/setup/cancel');
+ const again = await session.post('/api/2fa/setup/cancel');
+ const confirm = await session.post('/api/2fa/setup/confirm', { token: '000000' });
+ const anonymous = await api.post('/api/2fa/setup/cancel');
+ const viaApiKey = await apiKey.post('/api/2fa/setup/cancel');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+ // Cancelling is idempotent.
+ expect(again.status).toBe(200);
+ expect(again.body).toEqual({ ok: true });
+ expect(confirm.status).toBe(400);
+ expect(confirm.body.error.code).toBe('two-factor-error-no-pending-setup');
+ expect(anonymous.status).toBe(401);
+ expect(viaApiKey.status).toBe(401);
+ });
+
+ test('POST /api/2fa/setup/initiate can be repeated and rejects wrong codes', async ({ api }) => {
+ const session = api.bearer(sessionToken);
+ const response = await session.post('/api/2fa/setup/initiate');
+
+ expect(response.status).toBe(200);
+
+ secret = response.body.manualKey;
+
+ // A single wrong attempt (the lockout threshold is five).
+ const invalid = await session.post('/api/2fa/setup/confirm', { token: wrongCode(secret) });
+
+ expect(invalid.status).toBe(400);
+ expect(invalid.body.error).toMatchObject({
+ code: 'two-factor-error-invalid-code',
+ message: 'Invalid verification code',
+ status: 400,
+ });
+ });
+
+ test('POST /api/2fa/setup/confirm enables 2FA and returns backup codes', async ({ api }) => {
+ const session = api.bearer(sessionToken);
+ lastCode = await nextCode(secret);
+
+ const response = await session.post('/api/2fa/setup/confirm', { token: lastCode });
+
+ expect(response.status).toBe(200);
+ expect(response.body.backupCodes).toHaveLength(10);
+ expect(new Set(response.body.backupCodes).size).toBe(10);
+
+ for (const code of response.body.backupCodes) {
+ expect(typeof code).toBe('string');
+ expect(code.length).toBeGreaterThan(0);
+ }
+
+ backupCodes = response.body.backupCodes;
+
+ const status = await session.get('/api/2fa/status');
+
+ expect(status.body).toMatchObject({ isEnabled: true, isRequired: false });
+
+ // Confirming again is rejected: there is no longer a pending setup.
+ const again = await session.post('/api/2fa/setup/confirm', { token: lastCode });
+
+ expect(again.status).toBe(400);
+ expect(again.body.error.code).toBe('two-factor-error-no-pending-setup');
+ });
+
+ test('POST /api/2fa/setup/initiate rejects users with 2FA enabled', async ({ api }) => {
+ const session = api.bearer(sessionToken);
+ const response = await session.post('/api/2fa/setup/initiate');
+
+ expect(response.status).toBe(400);
+ expect(response.body.error.code).toBe('two-factor-error-already-enabled');
+ });
+
+ test('POST /api/auth/login returns a partial token when 2FA is enabled', async ({ api }) => {
+ const response = await api.post('/api/auth/login', { username, password });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ requiresTwoFactor: true, partialToken: expect.any(String) });
+
+ partialToken = response.body.partialToken;
+
+ // NOTE: the partial token is currently accepted as a full session by
+ // checkAuth (it only looks at payload.userId, not payload.type), which
+ // bypasses the second factor. Not asserted here; reported as a bug.
+ });
+
+ test('POST /api/2fa/verify rejects missing, invalid and non-partial tokens', async ({
+ api,
+ apiKey,
+ }) => {
+ const session = api.bearer(sessionToken);
+ const body = { token: '000000' };
+ const missing = await api.post('/api/2fa/verify', body);
+ const garbage = await api.bearer('not-a-token').post('/api/2fa/verify', body);
+ const fullSession = await session.post('/api/2fa/verify', body);
+ const viaApiKey = await apiKey.post('/api/2fa/verify', body);
+
+ expect(missing.status).toBe(401);
+ expect(missing.body.error.code).toBe('two-factor-error-missing-token');
+ expect(garbage.status).toBe(401);
+ expect(garbage.body.error.code).toBe('two-factor-error-invalid-partial-token');
+ expect(fullSession.status).toBe(401);
+ expect(fullSession.body.error.code).toBe('two-factor-error-invalid-partial-token');
+ expect(viaApiKey.status).toBe(401);
+ });
+
+ test('POST /api/2fa/verify validates the body', async ({ api }) => {
+ const partial = api.bearer(partialToken);
+ const empty = await partial.post('/api/2fa/verify', {});
+ const both = await partial.post('/api/2fa/verify', { token: '000000', backupCode: 'x' });
+ const extra = await partial.post('/api/2fa/verify', { token: '000000', extra: true });
+ const tooShort = await partial.post('/api/2fa/verify', { token: '12345' });
+ const emptyBackup = await partial.post('/api/2fa/verify', { backupCode: '' });
+
+ expect(empty.status).toBe(400);
+ expect(both.status).toBe(400);
+ expect(extra.status).toBe(400);
+ expect(tooShort.status).toBe(400);
+ expect(emptyBackup.status).toBe(400);
+ });
+
+ test('POST /api/2fa/verify rejects a code that was already used', async ({ api }) => {
+ // The code accepted by setup/confirm stays blocked for 90 s.
+ const response = await api.bearer(partialToken).post('/api/2fa/verify', { token: lastCode });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toMatchObject({
+ code: 'two-factor-error-code-used',
+ message: 'Code already used',
+ });
+ });
+
+ test('POST /api/2fa/verify completes the login with a TOTP code', async ({ api }) => {
+ const code = await nextCode(secret, lastCode);
+ const response = await api.bearer(partialToken).post('/api/2fa/verify', { token: code });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ token: expect.any(String),
+ user: { id: userId, username, role: 'user', isAdmin: false, teams: expect.any(Array) },
+ });
+ expect(response.body).not.toHaveProperty('requiresTwoFactor');
+
+ lastCode = code;
+
+ // The issued token is a full session.
+ const me = await api.bearer(response.body.token).get('/api/me');
+
+ expect(me.status).toBe(200);
+ expect(me.body.user.id).toBe(userId);
+ });
+
+ test('POST /api/2fa/verify accepts a backup code once', async ({ api }) => {
+ const loginResponse = await api.post('/api/auth/login', { username, password });
+
+ expect(loginResponse.body.requiresTwoFactor).toBe(true);
+
+ const partial = api.bearer(loginResponse.body.partialToken);
+ const response = await partial.post('/api/2fa/verify', { backupCode: backupCodes[0] });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ token: expect.any(String), user: { id: userId } });
+
+ // Backup codes are single-use (this counts as one failed attempt).
+ const reused = await api
+ .bearer((await api.post('/api/auth/login', { username, password })).body.partialToken)
+ .post('/api/2fa/verify', { backupCode: backupCodes[0] });
+
+ expect(reused.status).toBe(400);
+ expect(reused.body.error.code).toBe('two-factor-error-invalid-backup-code');
+ });
+
+ test('POST /api/2fa/disable validates the body and password', async ({ api, apiKey }) => {
+ const session = api.bearer(sessionToken);
+ const missingToken = await session.post('/api/2fa/disable', { password });
+ const missingPassword = await session.post('/api/2fa/disable', { token: '000000' });
+ const shortToken = await session.post('/api/2fa/disable', { password, token: '123' });
+ const wrongPassword = await session.post('/api/2fa/disable', {
+ password: 'wrong-password',
+ token: '000000',
+ });
+ const anonymous = await api.post('/api/2fa/disable', { password, token: '000000' });
+ const viaApiKey = await apiKey.post('/api/2fa/disable', { password, token: '000000' });
+
+ expect(missingToken.status).toBe(400);
+ expect(missingPassword.status).toBe(400);
+ expect(shortToken.status).toBe(400);
+ expect(wrongPassword.status).toBe(400);
+ expect(wrongPassword.body.error).toMatchObject({
+ code: 'two-factor-error-incorrect-password',
+ message: 'Incorrect password',
+ });
+ expect(anonymous.status).toBe(401);
+ expect(viaApiKey.status).toBe(401);
+ });
+
+ test('POST /api/2fa/disable is forbidden while 2FA is required', async ({ api, admin }) => {
+ const session = api.bearer(sessionToken);
+ const required = await admin.post(`/api/admin/users/${userId}/2fa`, { required: true });
+
+ expect(required.status).toBe(200);
+
+ try {
+ const status = await session.get('/api/2fa/status');
+ const response = await session.post('/api/2fa/disable', { password, token: '000000' });
+
+ expect(status.body).toMatchObject({
+ isEnabled: true,
+ isRequired: true,
+ requiredReason: 'user',
+ });
+ expect(response.status).toBe(403);
+ expect(response.body.error).toMatchObject({
+ code: 'two-factor-error-disable-not-allowed',
+ message: '2FA is required and cannot be disabled',
+ });
+ } finally {
+ const optional = await admin.post(`/api/admin/users/${userId}/2fa`, { required: false });
+
+ expect(optional.status).toBe(200);
+ }
+ });
+
+ test('POST /api/2fa/disable turns 2FA off', async ({ api, admin }) => {
+ const session = api.bearer(sessionToken);
+ const code = await nextCode(secret, lastCode);
+ const response = await session.post('/api/2fa/disable', { password, token: code });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+
+ lastCode = code;
+
+ const status = await session.get('/api/2fa/status');
+ const adminView = await admin.get(`/api/admin/users/${userId}/2fa`);
+ const loginResponse = await api.post('/api/auth/login', { username, password });
+ const again = await session.post('/api/2fa/disable', { password, token: code });
+
+ expect(status.body).toMatchObject({ isEnabled: false, isRequired: false });
+ expect(adminView.body).toEqual({ isEnabled: false });
+ // Login is back to a single step.
+ expect(loginResponse.status).toBe(200);
+ expect(loginResponse.body.token).toEqual(expect.any(String));
+ expect(loginResponse.body).not.toHaveProperty('requiresTwoFactor');
+ expect(again.status).toBe(400);
+ expect(again.body.error.code).toBe('two-factor-error-not-enabled');
+ });
+});
diff --git a/tests/api/users.spec.ts b/tests/api/users.spec.ts
new file mode 100644
index 000000000..e407333d7
--- /dev/null
+++ b/tests/api/users.spec.ts
@@ -0,0 +1,161 @@
+import { expect, test } from './fixtures';
+import { login } from './helpers/auth';
+import { UNKNOWN_UUID } from './helpers/constants';
+import { uniqueName } from './helpers/entities';
+
+test.describe('Users', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ const username = uniqueName('user');
+ const password = 'spec-password-1';
+ let userId = '';
+
+ test.afterAll(async ({ admin }) => {
+ if (userId) {
+ await admin.del(`/api/users/${userId}`);
+ }
+ });
+
+ test('POST /api/users creates a user', async ({ admin }) => {
+ const response = await admin.post('/api/users', {
+ username: username.toUpperCase(),
+ password,
+ role: 'user',
+ });
+
+ expect(response.status).toBe(200);
+ // Usernames are stored lowercased.
+ expect(response.body).toMatchObject({ username, role: 'user' });
+
+ userId = response.body.id;
+ });
+
+ test('POST /api/users rejects duplicates and invalid bodies', async ({ admin }) => {
+ const duplicate = await admin.post('/api/users', { username, password, role: 'user' });
+ const shortPassword = await admin.post('/api/users', {
+ username: uniqueName('user'),
+ password: 'short',
+ role: 'user',
+ });
+ const badRole = await admin.post('/api/users', {
+ username: uniqueName('user'),
+ password,
+ role: 'superuser',
+ });
+
+ expect(duplicate.status).toBe(400);
+ expect(duplicate.body.error.message).toBe('User already exists');
+ expect(shortPassword.status).toBe(400);
+ expect(badRole.status).toBe(400);
+ });
+
+ test('POST /api/users requires an admin session', async ({ api, user, apiKey }) => {
+ const body = { username: uniqueName('user'), password, role: 'user' };
+
+ expect((await api.post('/api/users', body)).status).toBe(401);
+ expect((await user.post('/api/users', body)).status).toBe(401);
+ // API keys are blocked on /api/users even for admins.
+ expect((await apiKey.post('/api/users', body)).status).toBe(401);
+ });
+
+ test('GET /api/users/{userId} returns a user', async ({ admin, api, viewer }) => {
+ const response = await admin.get(`/api/users/${userId}`);
+ const self = await api
+ .bearer(await login(api, { username, password }))
+ .get(`/api/users/${userId}`);
+ const denied = await viewer.get(`/api/users/${userId}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ id: userId, username, role: 'user' });
+ expect(response.body).not.toHaveProperty('password');
+ expect(self.status).toBe(200);
+ expect(denied.status).toBe(401);
+ });
+
+ test('POST /api/users/{userId} lets admins update role, username and password', async ({
+ admin,
+ api,
+ }) => {
+ const newPassword = 'spec-password-2';
+ const response = await admin.post(`/api/users/${userId}`, {
+ role: 'view-only',
+ password: newPassword,
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ id: userId, username, role: 'view-only' });
+
+ // The new password works and the old one does not.
+ expect(await login(api, { username, password: newPassword })).toEqual(expect.any(String));
+ expect((await api.post('/api/auth/login', { username, password })).status).toBe(401);
+
+ await admin.post(`/api/users/${userId}`, { password });
+ });
+
+ test('POST /api/users/{userId} ignores role changes from non-admins', async ({ user, seed }) => {
+ const response = await user.post(`/api/users/${seed.user.id}`, { role: 'admin' });
+
+ expect(response.status).toBe(200);
+ expect(response.body.role).toBe('user');
+ });
+
+ test('POST /api/users/{userId} rejects unknown users and other users', async ({
+ admin,
+ user,
+ }) => {
+ const unknown = await admin.post(`/api/users/${UNKNOWN_UUID}`, { role: 'user' });
+ const denied = await user.post(`/api/users/${userId}`, { password: 'spec-password-3' });
+
+ expect(unknown.status).toBe(404);
+ expect(denied.status).toBe(401);
+ });
+
+ test('GET /api/users/{userId}/websites lists a user websites', async ({
+ admin,
+ user,
+ viewer,
+ seed,
+ }) => {
+ const response = await admin.get(`/api/users/${seed.user.id}/websites`);
+ const self = await user.get(`/api/users/${seed.user.id}/websites`, {
+ params: { includeTeams: 'true' },
+ });
+ const denied = await viewer.get(`/api/users/${seed.user.id}/websites`);
+
+ expect(response.status).toBe(200);
+ expect(response.body.data.map((w: any) => w.id)).toContain(seed.website2.id);
+ expect(self.status).toBe(200);
+ expect(self.body.data.map((w: any) => w.id)).toContain(seed.website2.id);
+ expect(denied.status).toBe(401);
+ });
+
+ test('GET /api/users/{userId}/teams lists a user teams', async ({
+ admin,
+ user,
+ viewer,
+ seed,
+ }) => {
+ const response = await admin.get(`/api/users/${seed.user.id}/teams`);
+ const self = await user.get(`/api/users/${seed.user.id}/teams`);
+ const denied = await viewer.get(`/api/users/${seed.user.id}/teams`);
+
+ expect(response.status).toBe(200);
+ expect(response.body.data.map((t: any) => t.id)).toContain(seed.team.id);
+ expect(self.status).toBe(200);
+ expect(denied.status).toBe(401);
+ });
+
+ test('DELETE /api/users/{userId} deletes a user', async ({ admin, user, seed }) => {
+ const denied = await user.del(`/api/users/${userId}`);
+ const self = await admin.del(`/api/users/${seed.admin.id}`);
+ const response = await admin.del(`/api/users/${userId}`);
+
+ expect(denied.status).toBe(401);
+ expect(self.status).toBe(400);
+ expect(self.body.error.message).toBe('You cannot delete yourself.');
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+
+ userId = '';
+ });
+});
diff --git a/tests/api/websites.spec.ts b/tests/api/websites.spec.ts
new file mode 100644
index 000000000..921c610a8
--- /dev/null
+++ b/tests/api/websites.spec.ts
@@ -0,0 +1,293 @@
+import { randomUUID } from 'node:crypto';
+import { expect, test } from './fixtures';
+import { UNKNOWN_UUID } from './helpers/constants';
+import { dateRange } from './helpers/dates';
+import {
+ createTeam,
+ deleteTeam,
+ deleteWebsite,
+ uniqueDomain,
+ uniqueName,
+} from './helpers/entities';
+import { HOSTNAME } from './seed/dataset';
+
+test.describe('Websites', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let teamId = '';
+ let websiteId = '';
+ let teamWebsiteId = '';
+
+ test.beforeAll(async ({ admin }) => {
+ teamId = (await createTeam(admin)).id;
+ });
+
+ test.afterAll(async ({ admin }) => {
+ for (const id of [websiteId, teamWebsiteId]) {
+ if (id) {
+ await deleteWebsite(admin, id);
+ }
+ }
+
+ if (teamId) {
+ await deleteTeam(admin, teamId);
+ }
+ });
+
+ test('POST /api/websites creates a website for the current user', async ({ admin, seed }) => {
+ const name = uniqueName('website');
+ const domain = uniqueDomain();
+ const response = await admin.post('/api/websites', { name, domain });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ name, domain, userId: seed.admin.id, shareId: null });
+
+ websiteId = response.body.id;
+ });
+
+ test('POST /api/websites creates a website for a team', async ({ admin }) => {
+ const response = await admin.post('/api/websites', {
+ name: uniqueName('team-website'),
+ domain: uniqueDomain(),
+ teamId,
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ teamId, userId: null });
+
+ teamWebsiteId = response.body.id;
+ });
+
+ test('POST /api/websites accepts a fixed id', async ({ admin }) => {
+ const id = randomUUID();
+ const response = await admin.post('/api/websites', {
+ id,
+ name: uniqueName('website'),
+ domain: uniqueDomain(),
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.id).toBe(id);
+
+ await deleteWebsite(admin, id);
+ });
+
+ test('POST /api/websites validates the body', async ({ admin }) => {
+ const domain = await admin.post('/api/websites', { name: 'x', domain: 'not a domain' });
+ const name = await admin.post('/api/websites', { domain: uniqueDomain() });
+
+ expect(domain.status).toBe(400);
+ expect(domain.body.error.code).toBe('bad-request');
+ expect(name.status).toBe(400);
+ });
+
+ test('POST /api/websites requires a user that can create websites', async ({ api, viewer }) => {
+ const anonymous = await api.post('/api/websites', { name: 'x', domain: uniqueDomain() });
+ const viewOnly = await viewer.post('/api/websites', { name: 'x', domain: uniqueDomain() });
+
+ expect(anonymous.status).toBe(401);
+ expect(viewOnly.status).toBe(401);
+ });
+
+ test('GET /api/websites lists the current user websites', async ({ admin, user }) => {
+ const response = await admin.get('/api/websites');
+ const search = await admin.get('/api/websites', { params: { search: 'zzz-no-such-website' } });
+ const other = await user.get('/api/websites');
+
+ expect(response.status).toBe(200);
+ expect(response.body.data.map((w: any) => w.id)).toContain(websiteId);
+ expect(response.body.data.map((w: any) => w.id)).not.toContain(teamWebsiteId);
+ expect(search.body.data).toHaveLength(0);
+ expect(other.body.data.map((w: any) => w.id)).not.toContain(websiteId);
+ });
+
+ test('GET /api/websites?includeTeams includes team websites', async ({ admin }) => {
+ const response = await admin.get('/api/websites', { params: { includeTeams: 'true' } });
+
+ expect(response.status).toBe(200);
+ expect(response.body.data.map((w: any) => w.id)).toContain(teamWebsiteId);
+ });
+
+ test('GET /api/websites/{websiteId} returns a website', async ({
+ admin,
+ api,
+ viewer,
+ share,
+ seed,
+ }) => {
+ const response = await admin.get(`/api/websites/${websiteId}`);
+ const shared = await (await share()).get(`/api/websites/${seed.website.id}`);
+ const anonymous = await api.get(`/api/websites/${websiteId}`);
+ const denied = await viewer.get(`/api/websites/${websiteId}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body.id).toBe(websiteId);
+ expect(shared.status).toBe(200);
+ expect(shared.body.id).toBe(seed.website.id);
+ expect(anonymous.status).toBe(401);
+ expect(denied.status).toBe(401);
+ });
+
+ test('POST /api/websites/{websiteId} updates name and domain', async ({ admin, user }) => {
+ const name = uniqueName('renamed');
+ const domain = uniqueDomain();
+ const response = await admin.post(`/api/websites/${websiteId}`, { name, domain });
+ const denied = await user.post(`/api/websites/${websiteId}`, { name });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ id: websiteId, name, domain });
+ expect(denied.status).toBe(401);
+ });
+
+ test('POST /api/websites/{websiteId} manages the legacy share id', async ({ admin }) => {
+ const created = await admin.post(`/api/websites/${websiteId}`, { shareId: 'SPECSHARE1' });
+ const removed = await admin.post(`/api/websites/${websiteId}`, { shareId: null });
+
+ expect(created.status).toBe(200);
+ expect(created.body.shareId).toBe('SPECSHARE1');
+ expect(removed.status).toBe(200);
+ expect(removed.body.shareId).toBeNull();
+ });
+
+ test('POST /api/websites/{websiteId} updates the recorder config', async ({ admin, api }) => {
+ const enabled = await admin.post(`/api/websites/${websiteId}`, {
+ replayConfig: { replayEnabled: true, sampleRate: 0.5, maskLevel: 'strict' },
+ });
+ const recorder = await api.get(`/api/websites/${websiteId}/recorder`);
+ const disabled = await admin.post(`/api/websites/${websiteId}`, { replayConfig: null });
+ const recorderOff = await api.get(`/api/websites/${websiteId}/recorder`);
+
+ expect(enabled.status).toBe(200);
+ expect(recorder.status).toBe(200);
+ expect(recorder.body).toMatchObject({
+ enabled: true,
+ replayEnabled: true,
+ heatmapEnabled: false,
+ sampleRate: 0.5,
+ maskLevel: 'strict',
+ });
+ expect(disabled.status).toBe(200);
+ expect(recorderOff.body).toEqual({ enabled: false });
+ });
+
+ test('GET /api/websites/{websiteId}/recorder is public and CORS enabled', async ({ api }) => {
+ const response = await api.get(`/api/websites/${UNKNOWN_UUID}/recorder`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ enabled: false });
+ expect(response.headers['access-control-allow-origin']).toBe('*');
+ });
+
+ test('GET /api/websites/charts returns chart data for accessible websites', async ({
+ admin,
+ viewer,
+ seed,
+ }) => {
+ const response = await admin.get('/api/websites/charts', {
+ params: { ids: `${seed.website.id},${seed.website2.id}` },
+ });
+ const denied = await viewer.get('/api/websites/charts', { params: { ids: seed.website.id } });
+ const invalid = await admin.get('/api/websites/charts', { params: { ids: 'nope' } });
+
+ expect(response.status).toBe(200);
+ // `data` is keyed by website id; ids the caller cannot view are silently dropped.
+ expect(Object.keys(response.body.data).sort()).toEqual(
+ [seed.website.id, seed.website2.id].sort(),
+ );
+ expect(denied.status).toBe(200);
+ expect(denied.body.data).toEqual({});
+ expect(invalid.status).toBe(400);
+ });
+
+ test('GET /api/websites/{websiteId}/daterange returns the data window', async ({
+ admin,
+ share,
+ viewer,
+ seed,
+ }) => {
+ const response = await admin.get(`/api/websites/${seed.website.id}/daterange`);
+ const shared = await (await share()).get(`/api/websites/${seed.website.id}/daterange`);
+ const denied = await viewer.get(`/api/websites/${seed.website.id}/daterange`);
+
+ expect(response.status).toBe(200);
+ expect(new Date(response.body.startDate).getTime()).toBeGreaterThanOrEqual(seed.range.startAt);
+ expect(new Date(response.body.endDate).getTime()).toBeGreaterThanOrEqual(
+ new Date(response.body.startDate).getTime(),
+ );
+ expect(shared.status).toBe(200);
+ expect(denied.status).toBe(401);
+ });
+
+ test('GET /api/websites/{websiteId}/export returns a zip archive', async ({
+ admin,
+ share,
+ seed,
+ }) => {
+ const response = await admin.get(`/api/websites/${seed.website.id}/export`, {
+ params: dateRange(seed),
+ });
+ const shared = await (await share()).get(`/api/websites/${seed.website.id}/export`, {
+ params: dateRange(seed),
+ });
+ const missingRange = await admin.get(`/api/websites/${seed.website.id}/export`);
+
+ expect(response.status).toBe(200);
+ // base64 of the "PK\x03\x04" zip signature
+ expect(response.body.zip).toMatch(/^UEsDB/);
+ expect(shared.status).toBe(401);
+ expect(missingRange.status).toBe(400);
+ });
+
+ test('POST /api/websites/{websiteId}/reset clears the website data', async ({
+ admin,
+ api,
+ user,
+ }) => {
+ await api.post('/api/send', {
+ type: 'event',
+ payload: { website: websiteId, hostname: HOSTNAME, url: '/reset', ip: '10.0.9.3' },
+ });
+
+ const denied = await user.post(`/api/websites/${websiteId}/reset`);
+ const response = await admin.post(`/api/websites/${websiteId}/reset`);
+ const website = await admin.get(`/api/websites/${websiteId}`);
+
+ expect(denied.status).toBe(401);
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+ expect(website.body.resetAt).toBeTruthy();
+ });
+
+ test('POST /api/websites/{websiteId}/transfer moves a website between owners', async ({
+ admin,
+ user,
+ seed,
+ }) => {
+ const toTeam = await admin.post(`/api/websites/${websiteId}/transfer`, { teamId });
+ const toUser = await admin.post(`/api/websites/${websiteId}/transfer`, {
+ userId: seed.admin.id,
+ });
+ const empty = await admin.post(`/api/websites/${websiteId}/transfer`, {});
+ const denied = await user.post(`/api/websites/${websiteId}/transfer`, { userId: seed.user.id });
+
+ expect(toTeam.status).toBe(200);
+ expect(toTeam.body).toMatchObject({ teamId, userId: null });
+ expect(toUser.status).toBe(200);
+ expect(toUser.body).toMatchObject({ teamId: null, userId: seed.admin.id });
+ expect(empty.status).toBe(400);
+ expect(denied.status).toBe(401);
+ });
+
+ test('DELETE /api/websites/{websiteId} deletes a website', async ({ admin, user }) => {
+ const denied = await user.del(`/api/websites/${teamWebsiteId}`);
+ const response = await admin.del(`/api/websites/${websiteId}`);
+ const gone = await admin.get(`/api/websites/${websiteId}`);
+
+ expect(denied.status).toBe(401);
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ ok: true });
+ expect(gone.body).toBeNull();
+
+ websiteId = '';
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index a876a46bb..92ae5f450 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -39,5 +39,11 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
- "exclude": ["node_modules", "./playwright.config.ts", "tests/e2e"]
+ "exclude": [
+ "node_modules",
+ "./playwright.config.ts",
+ "./playwright.api.config.ts",
+ "tests/e2e",
+ "tests/api"
+ ]
}
diff --git a/vitest.config.ts b/vitest.config.ts
index 23a84a672..8816a15a8 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -9,7 +9,7 @@ export default defineConfig({
},
test: {
environment: 'jsdom',
- include: ['src/**/*.test.{ts,tsx}'],
+ include: ['src/**/*.test.{ts,tsx}', 'tests/api/coverage/**/*.test.ts'],
setupFiles: ['./src/test/setup.ts'],
},
});