feat(analytics): pluggable trackers — Umami + Rybbit + Custom (#663 Phase 1)
Implements the hybrid scope agreed on in #663: two native adapters (Umami + Rybbit) for trackers we'd keep maintained, plus a Custom script-paste mode for everyone else (Plausible, Matomo, Pirsch, GA4, GoatCounter, Fathom, Cloudflare Web Analytics). Phase 2 (Plausible native, deeper metrics) explicitly deferred until someone asks. ## Architecture **Backend `services/trackers/`**: - `TrackerAdapter` shape (single method): `fetchDeviceBreakdown` → `{ desktop, mobile, tablet } | null`. Null = route falls back to access_logs heuristic. - `umamiAdapter.js` — extracted from the `services/umamiClient.js` that landed in #662. Same 10 test contract preserved. - `rybbitAdapter.js` — new. Hits `/api/site/{id}/breakdown?dimension= device` with Bearer auth, accepts both bare-array and `{data:[...]}` envelope variants, tolerates `sessions`/`visitors`/`value`/`count` metric keys. - `customScriptSanitiser.js` — sanitize-html with a tracker-tight allowlist (`<script>` / `<noscript>` / `<link rel=preconnect| dns-prefetch>` / `<meta>`). Strips event-handler attributes, `javascript:` and `data:` URLs. - `index.js` factory: `resolveAdapter()` reads `analytics_tracker_provider` setting → dispatches. Back-compat: when provider is unset, infers `umami` from the legacy `analytics_umami_enabled` flag so #662 installs keep working without an admin touching settings. **Backend routes**: - `adminDashboard.js /analytics`: now goes through `resolveAdapter()`. Old `fetchUmamiDeviceBreakdown` direct import removed; both `umamiClient.js` and its test file deleted (replaced by the adapter shape). - `adminSettings.js PUT /analytics`: validates the new `analytics_tracker_provider` enum, sanitises any incoming `analytics_custom_head_html` on save via the sanitiser. Masks the new `analytics_rybbit_api_key` on every GET — same pattern as Umami's API key and recaptcha secret. - `publicSettings.js`: emits `analytics_tracker_provider`, `rybbit_url`/`rybbit_website_id` (only when provider=rybbit), and the pre-sanitised `analytics_custom_head_html` (only when provider=custom). Legacy `umami_*` fields stay for back-compat. **Frontend**: - `analytics.service.ts` reworked into a provider-aware shape. `initialize({provider, ...config})` dispatches to Umami / Rybbit / Custom / None. `track()` calls dispatch to `window.umami.track` / `window.rybbit.event` / no-op based on the loaded provider. - `App.tsx` `AnalyticsBootstrap` reads `analytics_tracker_provider` from public-settings and routes to the right `initialize` call. Legacy `umami_enabled`-based path preserved as fallback when the new field is missing. - `AnalyticsTab.tsx` (Settings → Analytics) reworked with a "Provider" dropdown switching between None / Umami / Rybbit / Custom panels. Each panel renders its own config fields; Custom panel surfaces an explicit CSP-reminder banner. - `useSettingsState.ts` shape extended with `tracker_provider`, `rybbit_url`/`rybbit_website_id`/`rybbit_api_key`, `custom_head_html`. Save mutation keeps `umami_enabled` in sync with `tracker_provider==='umami'` for back-compat with downstream consumers (publicSettings shape, embedded iframe). - `publicSettings.service.ts` type extended. **i18n**: EN + DE for the provider heading + description + dropdown options + Rybbit fields + Custom HTML field + CSP warning. ## Custom mode — script execution caveat When the gallery `<head>` receives the custom HTML, simply assigning innerHTML to a container element wouldn't execute the embedded `<script>` tags (per the HTML spec, dynamically-inserted scripts via innerHTML are non-running). `analytics.service.ts:120-130` re-creates each `<script>` element manually so the browser actually evaluates it. Non-script nodes (link, meta, noscript) move in directly. ## Tests **Backend** (42 cases, all pass locally): - `umamiAdapter.test.js` (10) — pinned from the original `umamiClient.test.js`: missing-config / URL shape / encoding / payload normalisation / `laptop`→`desktop` / unknown buckets / empty / non-2xx / invalid JSON / network error. - `rybbitAdapter.test.js` (9) — same shape adapted for Rybbit: bare-array + envelope payload, `sessions`/`visitors`/`dimension` key tolerance, encoding, failure modes. - `trackerFactory.test.js` (6) — resolves null for `none`/`custom`, correct adapter for `umami`/`rybbit`, back-compat path via legacy `analytics_umami_enabled`, garbage-provider defensive null. - `customScriptSanitiser.test.js` (12) — Plausible-style passthrough, Umami-style passthrough, inline body passthrough, `<noscript>` allowed, `<link rel="preconnect|dns-prefetch">` allowed, `<link rel="stylesheet">` stripped, disallowed tags stripped, `javascript:`/`data:` URLs stripped, `on*` event handlers stripped, defensive on malformed input. - `analyticsDateMerge.test.js` (5) — preserved from #662. **Frontend**: full 84-case vitest suite green; tsc + eslint clean on changed files. Adapter changes are narrow refactors of code covered by backend tests; no new analytics-page unit test added. ## End-to-end smoke (dockerised backend + my changes mounted) ``` test 1 (back-compat: no provider, umami_enabled=true) → factory returns umami adapter, /analytics returns devicesSource:access_logs (umami fetch to fake host fails gracefully). ✓ test 2 (invalid provider value) → 400 "analytics_tracker_provider must be one of: none, umami, rybbit, custom" ✓ test 3 (save custom HTML with XSS payload) → stored sanitised: `<script>alert(1)</script>evil<script async defer data-domain="x.com" src="https://plausible.io/js/script.js"></script>` (<div> stripped; script tags survive but CSP `script-src 'self'` still blocks inline + non-allowlisted external at runtime) ✓ test 4 (public-settings exposes the provider switch) → `analytics_tracker_provider: 'custom'`, `analytics_custom_head_html: '<sanitised>'` ✓ ``` ## Out of scope (next discussions) - **Plausible native** — covered via Custom mode for now; native is Phase 2 if someone explicitly asks. - **CSP "trusted domains" admin input** — Phase 1.5. For now operators add their tracker domain to nginx/proxy CSP manually; the new CSP-reminder banner in the Custom panel makes that clear. - **Refactor `(window as any).umami.track(...)` direct calls** in PhotoLightbox/PhotoGrid to go through `analyticsService.track()` so events fire on the right tracker. Currently a no-op when Umami isn't loaded; functional but not optimal. Closes #663 Phase 1.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tests for the custom-tracker HTML sanitiser (#663 Phase 1).
|
||||
*
|
||||
* The field accepts admin-pasted `<head>`-style snippets for arbitrary
|
||||
* trackers (Plausible / Matomo / Pirsch / GA4 / GoatCounter / Fathom /
|
||||
* Cloudflare Web Analytics). We sanitise on save with a narrow allowlist
|
||||
* tuned for tracker scripts — defence-in-depth, even though the field is
|
||||
* admin-only.
|
||||
*/
|
||||
|
||||
const { sanitizeTrackerSnippet } = require('../../src/services/trackers/customScriptSanitiser');
|
||||
|
||||
describe('sanitizeTrackerSnippet (#663)', () => {
|
||||
test('returns empty string for non-string / empty / whitespace input', () => {
|
||||
expect(sanitizeTrackerSnippet(null)).toBe('');
|
||||
expect(sanitizeTrackerSnippet(undefined)).toBe('');
|
||||
expect(sanitizeTrackerSnippet(42)).toBe('');
|
||||
expect(sanitizeTrackerSnippet('')).toBe('');
|
||||
expect(sanitizeTrackerSnippet(' ')).toBe('');
|
||||
});
|
||||
|
||||
test('passes through a Plausible-style script tag with data-domain', () => {
|
||||
const input = '<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).toContain('src="https://plausible.io/js/script.js"');
|
||||
expect(out).toContain('data-domain="example.com"');
|
||||
expect(out).toContain('defer');
|
||||
});
|
||||
|
||||
test('passes through a Umami-style script with data-website-id', () => {
|
||||
const input = '<script async defer src="https://analytics.example.com/script.js" data-website-id="aaa-bbb-ccc"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).toContain('src="https://analytics.example.com/script.js"');
|
||||
expect(out).toContain('data-website-id="aaa-bbb-ccc"');
|
||||
});
|
||||
|
||||
test('passes through inline script body unchanged', () => {
|
||||
const input = '<script>window.GA = "x"; window.tracker = function() { console.log("init"); };</script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).toContain('window.GA = "x"');
|
||||
expect(out).toContain('console.log("init")');
|
||||
});
|
||||
|
||||
test('allows <noscript> fallback', () => {
|
||||
const input = '<noscript><img src="https://t.example/?nojs=1" /></noscript>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).toContain('<noscript>');
|
||||
});
|
||||
|
||||
test('allows <link rel="preconnect"> and <link rel="dns-prefetch">', () => {
|
||||
const out = sanitizeTrackerSnippet(
|
||||
'<link rel="preconnect" href="https://t.example.com">'
|
||||
+ '<link rel="dns-prefetch" href="https://t.example.com">',
|
||||
);
|
||||
expect(out).toContain('rel="preconnect"');
|
||||
expect(out).toContain('rel="dns-prefetch"');
|
||||
expect(out).toContain('href="https://t.example.com"');
|
||||
});
|
||||
|
||||
test('strips <link rel="stylesheet"> (not tracker-related)', () => {
|
||||
const out = sanitizeTrackerSnippet('<link rel="stylesheet" href="https://evil.example/x.css">');
|
||||
expect(out).not.toContain('stylesheet');
|
||||
expect(out).not.toContain('href');
|
||||
});
|
||||
|
||||
test('strips disallowed tags entirely', () => {
|
||||
const input = '<div><iframe src="https://evil.example/x.html"></iframe><h1>hi</h1></div>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).not.toContain('iframe');
|
||||
expect(out).not.toContain('<div');
|
||||
expect(out).not.toContain('<h1');
|
||||
});
|
||||
|
||||
test('strips javascript: URLs from script src', () => {
|
||||
const input = '<script src="javascript:alert(1)"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('strips data: URLs from script src', () => {
|
||||
const input = '<script src="data:text/javascript,alert(1)"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).not.toContain('data:text/javascript');
|
||||
});
|
||||
|
||||
test('strips on* event-handler attributes (defence-in-depth)', () => {
|
||||
// event-handler attrs are not in our allowlist; sanitize-html strips them.
|
||||
const input = '<script src="https://t.example/x.js" onload="evil()"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).not.toContain('onload');
|
||||
expect(out).toContain('src="https://t.example/x.js"');
|
||||
});
|
||||
|
||||
test('returns empty string on unparseable input rather than throwing', () => {
|
||||
// sanitize-html is fault-tolerant — pass deliberately malformed and
|
||||
// confirm we don't blow up.
|
||||
expect(typeof sanitizeTrackerSnippet('<<<>>>')).toBe('string');
|
||||
expect(typeof sanitizeTrackerSnippet('<script')).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Tests for the Rybbit metrics-API adapter (#663 Phase 1). Mirrors the
|
||||
* `umamiAdapter` test contract: missing config / URL shape / encoding /
|
||||
* normalisation / unknown-bucket drop / failure modes.
|
||||
*
|
||||
* Rybbit's documented endpoint is `/api/site/{websiteId}/breakdown` with
|
||||
* `dimension=device`; we accept both bare-array and `{ data: [...] }`
|
||||
* envelopes since their docs hint at minor v0 → v1 shape variation.
|
||||
*/
|
||||
|
||||
const { buildAdapter } = require('../../src/services/trackers/rybbitAdapter');
|
||||
|
||||
const ORIGINAL_FETCH = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = ORIGINAL_FETCH;
|
||||
});
|
||||
|
||||
function mockJson(body, { status = 200 } = {}) {
|
||||
global.fetch = jest.fn(async () => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
}));
|
||||
}
|
||||
|
||||
const valid = { baseUrl: 'https://r.example.com', websiteId: 'rsite-789', apiKey: 'rkey' };
|
||||
|
||||
describe('rybbitAdapter.fetchDeviceBreakdown (#663)', () => {
|
||||
test('returns null when config is incomplete', async () => {
|
||||
expect(await buildAdapter({}).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
expect(global.fetch).toBe(ORIGINAL_FETCH);
|
||||
});
|
||||
|
||||
test('builds the expected URL + sends Bearer auth', async () => {
|
||||
mockJson([{ device: 'desktop', sessions: 10 }]);
|
||||
await buildAdapter({ ...valid, baseUrl: 'https://r.example.com/' })
|
||||
.fetchDeviceBreakdown({ startMs: 1700000000000, endMs: 1700003600000 });
|
||||
const [calledUrl, init] = global.fetch.mock.calls[0];
|
||||
expect(calledUrl).toMatch(/^https:\/\/r\.example\.com\/api\/site\/rsite-789\/breakdown\?dimension=device&start=.*&end=.*$/);
|
||||
expect(init.headers.Authorization).toBe('Bearer rkey');
|
||||
expect(init.method).toBe('GET');
|
||||
});
|
||||
|
||||
test('URL-encodes the websiteId for reserved chars', async () => {
|
||||
mockJson([{ device: 'desktop', sessions: 1 }]);
|
||||
await buildAdapter({ ...valid, websiteId: 'a/b?c' }).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
|
||||
const [calledUrl] = global.fetch.mock.calls[0];
|
||||
expect(calledUrl).toContain('/api/site/a%2Fb%3Fc/breakdown');
|
||||
});
|
||||
|
||||
test('normalises a typical {device, sessions} payload into percentages', async () => {
|
||||
mockJson([
|
||||
{ device: 'desktop', sessions: 60 },
|
||||
{ device: 'mobile', sessions: 30 },
|
||||
{ device: 'tablet', sessions: 10 },
|
||||
]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
|
||||
.toEqual({ desktop: 60, mobile: 30, tablet: 10 });
|
||||
});
|
||||
|
||||
test('accepts the {data: [...]} envelope variant', async () => {
|
||||
mockJson({ data: [
|
||||
{ device: 'desktop', sessions: 1 },
|
||||
{ device: 'mobile', sessions: 3 },
|
||||
] });
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
|
||||
.toEqual({ desktop: 25, mobile: 75, tablet: 0 });
|
||||
});
|
||||
|
||||
test('falls back to `visitors` when `sessions` is absent', async () => {
|
||||
mockJson([
|
||||
{ device: 'desktop', visitors: 80 },
|
||||
{ device: 'mobile', visitors: 20 },
|
||||
]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
|
||||
.toEqual({ desktop: 80, mobile: 20, tablet: 0 });
|
||||
});
|
||||
|
||||
test('tolerates a `dimension` key as the bucket label', async () => {
|
||||
mockJson([
|
||||
{ dimension: 'desktop', sessions: 50 },
|
||||
{ dimension: 'mobile', sessions: 50 },
|
||||
]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
|
||||
.toEqual({ desktop: 50, mobile: 50, tablet: 0 });
|
||||
});
|
||||
|
||||
test('drops unknown buckets', async () => {
|
||||
mockJson([
|
||||
{ device: 'desktop', sessions: 80 },
|
||||
{ device: 'mobile', sessions: 20 },
|
||||
{ device: 'fridge', sessions: 100 },
|
||||
]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
|
||||
.toEqual({ desktop: 80, mobile: 20, tablet: 0 });
|
||||
});
|
||||
|
||||
test('returns null on empty payload, non-2xx, invalid JSON, and network error', async () => {
|
||||
mockJson([]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
|
||||
mockJson({ error: 'unauthorized' }, { status: 401 });
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
|
||||
global.fetch = jest.fn(async () => ({
|
||||
ok: true, status: 200,
|
||||
json: async () => { throw new SyntaxError('not json'); },
|
||||
}));
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
|
||||
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Factory tests for the pluggable-tracker registry (#663 Phase 1).
|
||||
*
|
||||
* Pins the contract that drives `adminDashboard.js` analytics route:
|
||||
* - Returns null for 'none' / 'custom' / unset → route falls back to access_logs.
|
||||
* - Returns an Umami adapter shape for provider='umami'.
|
||||
* - Returns a Rybbit adapter shape for provider='rybbit'.
|
||||
* - Back-compat: when `analytics_tracker_provider` is unset, infers
|
||||
* 'umami' from the legacy `analytics_umami_enabled` flag.
|
||||
* - Invalid provider strings fall through to the legacy back-compat path
|
||||
* rather than crashing (defensive).
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-tracker-fact-')), 'db.sqlite',
|
||||
);
|
||||
|
||||
const { bootCrmDb } = require('../integration/helpers/crmDb');
|
||||
const trackers = require('../../src/services/trackers');
|
||||
|
||||
let db; let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('app_settings').del();
|
||||
});
|
||||
|
||||
async function setSetting(key, value) {
|
||||
await db('app_settings').insert({
|
||||
setting_key: key,
|
||||
setting_value: JSON.stringify(value),
|
||||
setting_type: 'analytics',
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
describe('resolveAdapter (#663)', () => {
|
||||
test('returns null when provider=\'none\'', async () => {
|
||||
await setSetting('analytics_tracker_provider', 'none');
|
||||
expect(await trackers.resolveAdapter()).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when provider=\'custom\' (no metrics adapter, just a script slot)', async () => {
|
||||
await setSetting('analytics_tracker_provider', 'custom');
|
||||
expect(await trackers.resolveAdapter()).toBeNull();
|
||||
});
|
||||
|
||||
test('back-compat: provider unset + legacy umami_enabled=true → umami adapter', async () => {
|
||||
await setSetting('analytics_umami_enabled', true);
|
||||
await setSetting('analytics_umami_url', 'https://u.example');
|
||||
await setSetting('analytics_umami_website_id', 'w-1');
|
||||
await setSetting('analytics_umami_api_key', 'k-1');
|
||||
const adapter = await trackers.resolveAdapter();
|
||||
expect(adapter).not.toBeNull();
|
||||
expect(adapter.provider).toBe('umami');
|
||||
});
|
||||
|
||||
test('provider=\'umami\' explicit → umami adapter with stored secrets', async () => {
|
||||
await setSetting('analytics_tracker_provider', 'umami');
|
||||
await setSetting('analytics_umami_url', 'https://u.example');
|
||||
await setSetting('analytics_umami_website_id', 'w-1');
|
||||
await setSetting('analytics_umami_api_key', 'k-1');
|
||||
const adapter = await trackers.resolveAdapter();
|
||||
expect(adapter.provider).toBe('umami');
|
||||
});
|
||||
|
||||
test('provider=\'rybbit\' → rybbit adapter with stored secrets', async () => {
|
||||
await setSetting('analytics_tracker_provider', 'rybbit');
|
||||
await setSetting('analytics_rybbit_url', 'https://r.example');
|
||||
await setSetting('analytics_rybbit_website_id', 'r-1');
|
||||
await setSetting('analytics_rybbit_api_key', 'rk-1');
|
||||
const adapter = await trackers.resolveAdapter();
|
||||
expect(adapter.provider).toBe('rybbit');
|
||||
});
|
||||
|
||||
test('garbage provider value falls through to legacy back-compat (defensive)', async () => {
|
||||
await setSetting('analytics_tracker_provider', 'plausible-not-yet-supported');
|
||||
// No legacy umami_enabled → resolves to null (= 'none')
|
||||
expect(await trackers.resolveAdapter()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Adapter-style tests for the Umami metrics client (#663 Phase 1, replaces
|
||||
* the old `umamiClient.test.js` from #662 — same contract, new shape).
|
||||
*
|
||||
* Pins the same 10 cases that protected the original implementation: missing
|
||||
* config / URL shape / encoding / payload normalisation / `laptop` mapping /
|
||||
* unknown-bucket drop / empty / non-2xx / invalid JSON / network error.
|
||||
*/
|
||||
|
||||
const { buildAdapter } = require('../../src/services/trackers/umamiAdapter');
|
||||
|
||||
const ORIGINAL_FETCH = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = ORIGINAL_FETCH;
|
||||
});
|
||||
|
||||
function mockJson(body, { status = 200 } = {}) {
|
||||
global.fetch = jest.fn(async () => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
}));
|
||||
}
|
||||
|
||||
const valid = { baseUrl: 'https://u.example.com', websiteId: 'site-123', apiKey: 'secret' };
|
||||
|
||||
describe('umamiAdapter.fetchDeviceBreakdown (#663)', () => {
|
||||
test('returns null when config is incomplete (back-compat path)', async () => {
|
||||
const a = buildAdapter({});
|
||||
expect(await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
const b = buildAdapter({ baseUrl: 'https://u' });
|
||||
expect(await b.fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
expect(global.fetch).toBe(ORIGINAL_FETCH);
|
||||
});
|
||||
|
||||
test('builds the expected URL + sends `x-umami-api-key` header', async () => {
|
||||
mockJson([{ x: 'desktop', y: 10 }]);
|
||||
const a = buildAdapter({ ...valid, baseUrl: 'https://u.example.com/' });
|
||||
await a.fetchDeviceBreakdown({ startMs: 1700000000000, endMs: 1700003600000 });
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
const [calledUrl, init] = global.fetch.mock.calls[0];
|
||||
expect(calledUrl).toBe(
|
||||
'https://u.example.com/api/websites/site-123/metrics?type=device&startAt=1700000000000&endAt=1700003600000',
|
||||
);
|
||||
expect(init.headers['x-umami-api-key']).toBe('secret');
|
||||
expect(init.method).toBe('GET');
|
||||
});
|
||||
|
||||
test('URL-encodes the websiteId for reserved chars', async () => {
|
||||
mockJson([{ x: 'desktop', y: 1 }]);
|
||||
const a = buildAdapter({ baseUrl: 'https://u', websiteId: 'a/b?c', apiKey: 'k' });
|
||||
await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
|
||||
const [calledUrl] = global.fetch.mock.calls[0];
|
||||
expect(calledUrl).toContain('/api/websites/a%2Fb%3Fc/metrics');
|
||||
});
|
||||
|
||||
test('normalises { x, y } payload into integer percentages', async () => {
|
||||
mockJson([
|
||||
{ x: 'desktop', y: 60 },
|
||||
{ x: 'mobile', y: 30 },
|
||||
{ x: 'tablet', y: 10 },
|
||||
]);
|
||||
const a = buildAdapter(valid);
|
||||
const out = await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
|
||||
expect(out).toEqual({ desktop: 60, mobile: 30, tablet: 10 });
|
||||
});
|
||||
|
||||
test('maps `laptop` into `desktop` (matches our 3-bucket UI)', async () => {
|
||||
mockJson([
|
||||
{ x: 'desktop', y: 50 },
|
||||
{ x: 'laptop', y: 20 },
|
||||
{ x: 'mobile', y: 30 },
|
||||
]);
|
||||
const out = await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
|
||||
expect(out).toEqual({ desktop: 70, mobile: 30, tablet: 0 });
|
||||
});
|
||||
|
||||
test('drops unknown buckets (no silent miscategorisation)', async () => {
|
||||
mockJson([
|
||||
{ x: 'desktop', y: 80 },
|
||||
{ x: 'mobile', y: 20 },
|
||||
{ x: 'unknown-future-bucket', y: 100 },
|
||||
]);
|
||||
const out = await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
|
||||
expect(out).toEqual({ desktop: 80, mobile: 20, tablet: 0 });
|
||||
});
|
||||
|
||||
test('returns null on empty payload', async () => {
|
||||
mockJson([]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null on non-2xx', async () => {
|
||||
mockJson({ error: 'unauthorized' }, { status: 401 });
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null on invalid JSON', async () => {
|
||||
global.fetch = jest.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => { throw new SyntaxError('not json'); },
|
||||
}));
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null on network error', async () => {
|
||||
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user