Files
Webklar-Kundenbereich/analytics/test/stats.test.js
T
ClaudeandClaude Opus 5.5 0db59d38e9 Analyse: Zeitraum = abgeschlossene Tage, "Heute bisher" getrennt, Feinschliff
Final-Review-Fixwelle der Cockpit-Übersicht:

- F1 Collector: Der Zeitraum sind die range Kalendertage bis gestern (Berliner Tag),
  der Vorzeitraum die range Tage davor; heute steht getrennt in today { day, pv, visitors,
  contacts }. Kein Einbruch mehr am rechten Rand von Diagramm und Mini-Verläufen,
  kein falsches "x % weniger" tagsüber. Portal: weekdayStats bekommt die ganze Reihe
  (completedDays entfällt), "Vergleich ab" = firstDay + 2 x range, Kopfzeile
  "Analyse aktiv · Heute bisher: N Aufrufe · Stand: HH:MM Uhr" (alter Collector ohne today
  bleibt ohne "Heute bisher").
- F2 "direkt kontaktiert" zählt nur Telefon, E-Mail und WhatsApp (auch im Vorzeitraum),
  Links nach außen nicht.
- F3 Mini-Verlauf "Ø Zeit pro Seite": Tage ohne Messung sind Lücken statt 0-Tälern.
- F4 Fehlerfarbe beim Ausschalten rot, --an-faint auf 55 %, Achsenschrift per
  Container-Abfragen auf 11-12 px (y-Rand wächst mit der Stellenzahl, x-Beschriftungen mit
  Mindestabstand), Ranglisten-Anteile an allen Seitenaufrufen, Tippflächen bis 880 px
  mindestens 40 px hoch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00

394 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { openDb } from '../src/db.js';
import { runNightly, nextRunAt, dayOf, scheduleNightly } from '../src/jobs.js';
import { createAdminServer } from '../src/server.js';
const KEY = 'abcdef0123456789abcd';
const OTHER = 'zzzzzzzz0123456789zz';
const NOW = Date.UTC(2026, 9, 10, 10, 0, 0); // 10.10.2026 12:00 Berlin
const H = 3600 * 1000;
const D = 24 * H;
function mk() {
const db = openDb(':memory:');
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(KEY, 'p', '["a.de"]');
const ev = (o) => db.prepare(`INSERT INTO events_raw (site, ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind, host)
VALUES (@site,@ts,@type,@path,@vp,@ref,@country,@device,@visitor,@sec,@scroll,@kind,@host)`)
.run({ site: KEY, path: null, vp: null, ref: null, country: null, device: null, visitor: null, sec: null, scroll: null, kind: null, host: null, ...o });
return { db, ev };
}
const daily = (db, site = KEY) => db.prepare('SELECT day, dim, key, count FROM daily WHERE site = ? ORDER BY day, dim, key').all(site);
const get = (db, day, dim, key = '') => db.prepare('SELECT count FROM daily WHERE site=? AND day=? AND dim=? AND key=?').get(KEY, day, dim, key)?.count;
test('Verdichtung: Dimensionen, nur Tage < heute, idempotent', () => {
const { db, ev } = mk();
const t = NOW - D; // 9.10.
ev({ ts: t, type: 'pv', path: '/', vp: 'd', ref: 'google.com', country: 'DE', device: 'd' });
ev({ ts: t + 1, type: 'pv', path: '/', vp: 'm', ref: null, country: 'DE', device: 'm' });
ev({ ts: t + 2, type: 'pv', path: '/x', vp: 'd', ref: 'google.com', country: 'AT', device: 'd' });
ev({ ts: t + 3, type: 'en', path: '/', vp: 'd', visitor: 'v1', sec: 10, scroll: 50 });
ev({ ts: t + 4, type: 'en', path: '/x', vp: 'd', visitor: 'v1', sec: 20, scroll: 70 });
ev({ ts: t + 5, type: 'ct', path: '/', visitor: 'v2', kind: 'tel' });
ev({ ts: t + 6, type: 'ct', path: '/', visitor: 'v2', kind: 'out', host: 'x.de' });
ev({ ts: t + 7, type: 'bn', kind: 'shown' });
ev({ ts: t + 8, type: 'bn', kind: 'accept' });
ev({ ts: NOW, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // heute: unberührt
runNightly(db, { now: NOW });
const d = '2026-10-09';
assert.equal(get(db, d, 'pv_total'), 3);
assert.equal(get(db, d, 'pv_path', '/'), 2);
assert.equal(get(db, d, 'pv_path', '/x'), 1);
assert.equal(get(db, d, 'pv_ref', 'google.com'), 2);
assert.equal(get(db, d, 'pv_ref', ''), 1);
assert.equal(get(db, d, 'pv_country', 'DE'), 2);
assert.equal(get(db, d, 'pv_device', 'm'), 1);
assert.equal(get(db, d, 'visitors'), 2); // v1, v2
assert.equal(get(db, d, 'en_count'), 2);
assert.equal(get(db, d, 'en_sec_sum'), 30);
assert.equal(get(db, d, 'scroll_sum'), 120);
assert.equal(get(db, d, 'ct_kind', 'tel'), 1);
assert.equal(get(db, d, 'ct_kind', 'out'), 1);
assert.equal(get(db, d, 'bn_shown'), 1);
assert.equal(get(db, d, 'bn_accept'), 1);
assert.equal(get(db, d, 'bn_reject'), undefined);
assert.equal(db.prepare("SELECT COUNT(*) c FROM daily WHERE day = '2026-10-10'").get().c, 0);
const before = daily(db);
runNightly(db, { now: NOW });
runNightly(db, { now: NOW + H });
assert.deepEqual(daily(db), before);
});
test('DST: 23:30 UTC im Sommer gehört zum nächsten Berlin-Tag', () => {
const { db, ev } = mk();
ev({ ts: Date.UTC(2026, 6, 14, 23, 30), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 15.7. 01:30 Berlin
ev({ ts: Date.UTC(2026, 6, 14, 21, 59), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 14.7. 23:59 Berlin
assert.equal(dayOf(Date.UTC(2026, 6, 14, 23, 30)), '2026-07-15');
runNightly(db, { now: Date.UTC(2026, 6, 16, 10) });
assert.equal(get(db, '2026-07-14', 'pv_total'), 1);
assert.equal(get(db, '2026-07-15', 'pv_total'), 1);
// Winter: 23:30 UTC = 00:30 Berlin am Folgetag
assert.equal(dayOf(Date.UTC(2026, 0, 14, 23, 30)), '2026-01-15');
});
test('Aufbewahrung: Rohdaten und Klicks > 30 Tage weg, vorher verdichtet', () => {
const { db, ev } = mk();
ev({ ts: NOW - 31 * D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: NOW - 29 * D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
const ins = db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y) VALUES (?,?,?,?,0.5,0.5)');
ins.run(KEY, NOW - 31 * D, '/', 'd'); ins.run(KEY, NOW - 29 * D, '/', 'd');
runNightly(db, { now: NOW });
assert.equal(db.prepare('SELECT COUNT(*) c FROM events_raw').get().c, 1);
assert.equal(db.prepare('SELECT COUNT(*) c FROM clicks').get().c, 1);
assert.equal(get(db, dayOf(NOW - 31 * D), 'pv_total'), 1); // in daily erhalten
});
test('Website mit offSince > 60 Tage wird komplett gelöscht', () => {
const { db, ev } = mk();
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active, offSince) VALUES (?,?,?,0,?)')
.run(OTHER, 'q', '["b.de"]', new Date(NOW - 61 * D).toISOString());
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active, offSince) VALUES (?,?,?,0,?)')
.run('young0123456789abcdef', 'r', '["c.de"]', new Date(NOW - 59 * D).toISOString());
ev({ site: OTHER, ts: NOW - D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: NOW - D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y) VALUES (?,?,?,?,0.1,0.1)').run(OTHER, NOW - D, '/', 'd');
runNightly(db, { now: NOW });
for (const t of ['sites', 'events_raw', 'clicks', 'daily', 'agg_state']) {
const col = t === 'sites' ? 'siteKey' : 'site';
assert.equal(db.prepare(`SELECT COUNT(*) c FROM ${t} WHERE ${col} = ?`).get(OTHER).c, 0, t);
}
assert.equal(db.prepare('SELECT COUNT(*) c FROM sites').get().c, 2); // KEY + junge
assert.ok(get(db, dayOf(NOW - D), 'pv_total'));
});
test('nextRunAt: 02:30 Berlin (Sommer/Winter)', () => {
assert.equal(new Date(nextRunAt(Date.UTC(2026, 9, 1, 10))).toISOString(), '2026-10-02T00:30:00.000Z');
assert.equal(new Date(nextRunAt(Date.UTC(2026, 9, 1, 0, 0))).toISOString(), '2026-10-01T00:30:00.000Z');
assert.equal(new Date(nextRunAt(Date.UTC(2026, 0, 1, 12))).toISOString(), '2026-01-02T01:30:00.000Z');
});
async function admin(db, now = () => NOW) {
const server = createAdminServer({ config: { adminToken: 'tok' }, db, now });
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
const get = (p, auth = 'Bearer tok') => fetch(base + p, { headers: auth ? { Authorization: auth } : {} });
return { get, close: () => server.close() };
}
test('Stats-Endpunkt: abgeschlossene Tage bis gestern, heute getrennt in today, Vergangenheit aus daily, consentRate', async () => {
const { db, ev } = mk();
const y = NOW - D;
for (let i = 0; i < 3; i++) ev({ ts: y + i, type: 'pv', path: '/a', vp: 'd', ref: 'g.de', country: 'DE', device: 'd' });
ev({ ts: y + 10, type: 'en', path: '/a', vp: 'd', visitor: 'v1', sec: 5, scroll: 5 });
ev({ ts: y + 11, type: 'bn', kind: 'accept' });
ev({ ts: y + 12, type: 'ct', path: '/a', visitor: 'v1', kind: 'tel' });
runNightly(db, { now: NOW }); // gestern -> daily
ev({ ts: NOW - H, type: 'pv', path: '/b', vp: 'm', country: 'AT', device: 'm' }); // heute live
ev({ ts: NOW - H, type: 'ct', path: '/b', visitor: 'v9', kind: 'mail' });
ev({ ts: NOW - H, type: 'bn', kind: 'accept' });
ev({ ts: NOW - H, type: 'bn', kind: 'reject' });
ev({ ts: NOW - H, type: 'bn', kind: 'reject' });
ev({ ts: NOW - H, type: 'bn', kind: 'shown' });
const a = await admin(db);
const r = await a.get(`/a/admin/stats/${KEY}?range=7`);
assert.equal(r.status, 200);
const j = await r.json();
assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'firstDay', 'from', 'pages', 'previous', 'range', 'refs', 'series', 'to', 'today', 'totals']);
// Der Zeitraum sind die 7 abgeschlossenen Tage bis gestern (3.–9.10.); heute (10.10.) steht nur in `today`
assert.equal(j.series.length, 7);
assert.equal(j.series[0].day, '2026-10-03');
assert.equal(j.series[6].day, '2026-10-09');
assert.deepEqual(j.series.map((s) => s.pv), [0, 0, 0, 0, 0, 0, 3]);
assert.deepEqual(j.totals, { pv: 3, visitors: 1, contacts: 1, consentRate: 1, engaged: 1, avgSec: 5, avgScroll: 5 });
assert.deepEqual(j.series[6], { day: '2026-10-09', pv: 3, visitors: 1, contacts: 1, avgSec: 5 });
assert.deepEqual(j.today, { day: '2026-10-10', pv: 1, visitors: 1, contacts: 1 });
assert.equal(j.from, '2026-10-03'); assert.equal(j.to, '2026-10-09'); assert.equal(j.range, 7);
assert.deepEqual(j.pages, [{ key: '/a', count: 3 }]);
assert.deepEqual(j.refs[0], { key: 'g.de', count: 3 });
assert.deepEqual(j.countries, [{ key: 'DE', count: 3 }]);
assert.deepEqual(j.devices, [{ key: 'd', count: 3 }]);
assert.deepEqual(j.contacts, [{ key: 'tel', count: 1 }]);
assert.deepEqual(j.banner, { shown: 0, accept: 1, reject: 0 });
// range 90: 90 Tage
const j90 = await (await a.get(`/a/admin/stats/${KEY}?range=90`)).json();
assert.equal(j90.series.length, 90);
assert.equal(j90.series[89].day, '2026-10-09');
assert.equal(j90.totals.pv, 3);
assert.deepEqual(j90.today, j.today);
a.close();
});
test('Stats: today = heutiger Berliner Tag bis jetzt, auch ohne Ereignisse; Mitternacht in Berlin trennt gestern und heute', async () => {
const { db, ev } = mk();
const a = await admin(db);
assert.deepEqual((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).today, { day: '2026-10-10', pv: 0, visitors: 0, contacts: 0 });
a.close();
// 11.10. 00:10 Berlin (= 10.10. 22:10 UTC, Sommerzeit): 23:00 Berlin am 10.10. gehört zu gestern, 00:05 Berlin am 11.10. zu heute
const LATE = Date.UTC(2026, 9, 10, 22, 10);
ev({ ts: Date.UTC(2026, 9, 10, 21, 0), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: Date.UTC(2026, 9, 10, 22, 5), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: Date.UTC(2026, 9, 10, 22, 6), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: Date.UTC(2026, 9, 10, 22, 7), type: 'ct', path: '/', visitor: 'v1', kind: 'wa' });
ev({ ts: Date.UTC(2026, 9, 10, 23, 0), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // noch in der Zukunft: zählt nicht
const b = await admin(db, () => LATE);
const j = await (await b.get(`/a/admin/stats/${KEY}?range=7`)).json();
assert.equal(j.to, '2026-10-10');
assert.equal(j.series[6].day, '2026-10-10'); assert.equal(j.series[6].pv, 1);
assert.equal(j.totals.pv, 1);
assert.deepEqual(j.today, { day: '2026-10-11', pv: 2, visitors: 1, contacts: 1 });
assert.equal(j.contacts.length, 0); // die Kontakt-Aktion von 00:07 ist heute
b.close();
});
test('Stats: gestern noch nicht verdichtet wird live gezählt, nie doppelt', async () => {
const { db, ev } = mk();
ev({ ts: NOW - D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: NOW - H, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // heute: nur in today
const a = await admin(db);
const stats = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json());
assert.equal((await stats()).totals.pv, 1);
runNightly(db, { now: NOW });
const j = await stats();
assert.equal(j.totals.pv, 1);
assert.equal(j.today.pv, 1);
a.close();
});
test('Stats: Vorzeitraum = die range Kalendertage vor dem aktuellen Zeitraum, complete nach firstDay', async () => {
const { db, ev } = mk();
// aktueller Zeitraum zu range 7 am 10.10.: 03.–09.10.; Vorzeitraum: 26.09.–02.10.
ev({ ts: Date.UTC(2026, 8, 30, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: Date.UTC(2026, 9, 1, 10), type: 'en', path: '/', vp: 'd', visitor: 'v1', sec: 20, scroll: 40 });
ev({ ts: Date.UTC(2026, 9, 2, 10), type: 'ct', path: '/', visitor: 'v2', kind: 'tel' });
const a = await admin(db);
const get = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json());
let j = await get();
assert.equal(j.firstDay, '2026-09-30');
assert.equal(j.previous.complete, false);
// 27.09. liegt noch einen Tag nach dem Beginn des Vorzeitraums (26.09.): weiter unvollständig
ev({ ts: Date.UTC(2026, 8, 27, 10), type: 'pv', path: '/x', vp: 'd', country: 'DE', device: 'd' });
j = await get();
assert.equal(j.firstDay, '2026-09-27');
assert.equal(j.previous.complete, false);
// genau am ersten Tag des Vorzeitraums: vollständig
ev({ ts: Date.UTC(2026, 8, 26, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
j = await get();
assert.equal(j.firstDay, '2026-09-26');
assert.equal(j.previous.complete, true);
// 25.09. liegt vor dem Vorzeitraum und darf nicht mitzählen
ev({ ts: Date.UTC(2026, 8, 25, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
j = await get();
assert.equal(j.firstDay, '2026-09-25');
assert.equal(j.previous.complete, true);
assert.equal(j.previous.from, '2026-09-26'); assert.equal(j.previous.to, '2026-10-02');
assert.equal(j.previous.series.length, 7); assert.equal(j.previous.series[4].day, '2026-09-30');
assert.equal(j.previous.totals.pv, 3); // 26.09., 27.09., 30.09.
assert.equal(j.previous.totals.engaged, 1);
assert.equal(j.previous.totals.avgSec, 20);
assert.equal(j.previous.totals.contacts, 1);
assert.deepEqual(j.previous.series[5], { day: '2026-10-01', pv: 0, visitors: 1, contacts: 0, avgSec: 20 });
assert.deepEqual(j.previous.series[6], { day: '2026-10-02', pv: 0, visitors: 1, contacts: 1, avgSec: null });
assert.deepEqual(j.previous.contacts, [{ key: 'tel', count: 1 }]);
assert.equal(j.totals.pv, 0); // aktueller Zeitraum leer
assert.equal(j.totals.avgSec, null);
a.close();
});
test('Stats: firstDay aus daily und Rohdaten, leer ohne Daten', async () => {
const { db, ev } = mk();
const a = await admin(db);
const first = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json());
let j = await first();
assert.equal(j.firstDay, '');
assert.equal(j.previous.complete, false);
// nur Rohdaten (heute)
ev({ ts: NOW - H, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
assert.equal((await first()).firstDay, '2026-10-10');
// 40 Tage alt: nach dem Nachtlauf nur noch in daily (Rohdaten > 30 Tage gelöscht)
ev({ ts: NOW - 40 * D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
assert.equal((await first()).firstDay, '2026-08-31');
runNightly(db, { now: NOW });
assert.equal(db.prepare('SELECT COUNT(*) c FROM events_raw WHERE ts < ?').get(NOW - 31 * D).c, 0);
j = await first();
assert.equal(j.firstDay, '2026-08-31');
assert.equal(j.previous.complete, true);
a.close();
});
test('Stats: avgSec/avgScroll gerundet, null ohne en; Tageswert avgSec', async () => {
const { db, ev } = mk();
const t = NOW - D; // 9.10.
ev({ ts: t, type: 'en', path: '/', vp: 'd', visitor: 'v1', sec: 30, scroll: 50 });
ev({ ts: t + 1, type: 'en', path: '/a', vp: 'd', visitor: 'v2', sec: 91, scroll: 100 });
const a = await admin(db);
const j = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json();
assert.equal(j.totals.engaged, 2);
assert.equal(j.totals.avgSec, 61); // 60,5 gerundet
assert.equal(j.totals.avgScroll, 75);
assert.equal(j.series[6].avgSec, 61); // 9.10. ist der letzte abgeschlossene Tag
assert.equal(j.series[5].avgSec, null);
// ohne en: beide null, engaged 0
const leer = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json();
assert.equal(leer.previous.totals.engaged, 0);
assert.equal(leer.previous.totals.avgSec, null);
assert.equal(leer.previous.totals.avgScroll, null);
a.close();
});
test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappung', async () => {
// range 30 um 23:30 Berlin: Zeitumstellung am 25.10. liegt im Zeitraum. Spätabends wäre now - 30 * 24 h
// schon der 12.10. (00:30 Berlin) und ein naives Fenster würde sich überlappen; mittags fiele das nicht auf.
const NOV = Date.UTC(2026, 10, 10, 22, 30);
const { db, ev } = mk();
ev({ ts: Date.UTC(2026, 9, 10, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 10.10. mittags: Vorzeitraum
ev({ ts: Date.UTC(2026, 9, 11, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 11.10. mittags: aktueller Zeitraum
ev({ ts: Date.UTC(2026, 10, 10, 12), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 10.11. mittags: heute
const a = await admin(db, () => NOV);
const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json();
assert.equal(j.series[0].day, '2026-10-11');
assert.equal(j.series[29].day, '2026-11-09');
assert.equal(j.previous.series[29].day, '2026-10-10');
assert.equal(j.previous.series[0].day, '2026-09-11');
assert.equal(j.from, '2026-10-11'); assert.equal(j.to, '2026-11-09');
assert.equal(j.previous.from, '2026-09-11'); assert.equal(j.previous.to, '2026-10-10');
const all = [...j.series, ...j.previous.series].map((s) => s.day);
assert.equal(all.length, 60);
assert.equal(new Set(all).size, 60);
assert.equal(j.previous.totals.pv, 1); assert.equal(j.previous.series[29].pv, 1);
assert.equal(j.totals.pv, 1); assert.equal(j.series[0].pv, 1);
assert.equal(j.today.day, '2026-11-10'); assert.equal(j.today.pv, 1);
a.close();
});
test('Stats: Vorzeitraum aus daily und live, nie doppelt', async () => {
const { db, ev } = mk();
ev({ ts: Date.UTC(2026, 9, 1, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: Date.UTC(2026, 9, 1, 11), type: 'ct', path: '/', visitor: 'v1', kind: 'mail' });
const a = await admin(db);
const get = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).previous;
const live = await get(); // noch nicht verdichtet: live aus den Rohdaten
assert.equal(live.totals.pv, 1);
runNightly(db, { now: NOW }); // jetzt aus daily
const verdichtet = await get();
assert.equal(verdichtet.totals.pv, 1);
assert.deepEqual(verdichtet, live);
runNightly(db, { now: NOW + H });
assert.deepEqual(await get(), live);
a.close();
});
test('Stats: Top 20 absteigend, consentRate 0 ohne Entscheidungen, Fehlerfälle', async () => {
const { db, ev } = mk();
for (let p = 0; p < 25; p++) for (let i = 0; i <= p; i++) ev({ ts: NOW - D - 1000, type: 'pv', path: `/p${p}`, vp: 'd', country: 'DE', device: 'd' });
const a = await admin(db);
const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json();
assert.equal(j.pages.length, 20);
assert.equal(j.pages[0].key, '/p24');
assert.ok(j.pages.every((e, i, arr) => !i || arr[i - 1].count >= e.count));
assert.equal(j.totals.consentRate, 0);
assert.equal((await a.get(`/a/admin/stats/${KEY}?range=5`)).status, 400);
assert.equal((await a.get(`/a/admin/stats/${KEY}?range=abc`)).status, 400);
assert.equal((await a.get(`/a/admin/stats/${OTHER}?range=7`)).status, 404);
assert.equal((await a.get(`/a/admin/stats/${KEY}?range=7`, null)).status, 401);
assert.equal((await a.get(`/a/admin/stats/${KEY}?range=7`, 'Bearer nope')).status, 401);
a.close();
});
test('scheduleNightly: früh feuernder Timer läuft nicht vorzeitig und nie doppelt für dasselbe 02:30', () => {
let t = Date.UTC(2026, 9, 10, 10, 0, 0);
const pending = [];
const timers = { setTimeout: (fn, ms) => { const h = { fn, at: t + ms }; pending.push(h); return h; }, clearTimeout: (h) => { const i = pending.indexOf(h); if (i >= 0) pending.splice(i, 1); } };
const runs = [];
const quiet = { log() {}, error() {} };
const s = scheduleNightly({}, { startDelayMs: 1000, log: quiet, now: () => t, timers, job: (db, o) => { runs.push(o.now); return { aggregated: 0, sitesDeleted: 0 }; } });
const fire = (clock) => { const h = pending.shift(); t = clock ?? h.at; h.fn(); };
fire(); // Startlauf
assert.equal(runs.length, 1);
const target = nextRunAt(t);
assert.equal(pending.length, 1);
assert.equal(pending[0].at, target);
fire(target - 500); // Timer feuert 500 ms zu früh
assert.equal(runs.length, 1, 'kein vorzeitiger Lauf');
assert.equal(pending.length, 1);
assert.equal(pending[0].at, target, 'neu auf das Ziel gesetzt');
fire(target); // pünktlich
assert.equal(runs.length, 2);
assert.equal(pending[0].at, nextRunAt(target + 1000));
assert.ok(pending[0].at > target + 20 * 3600000, 'nächster Lauf am Folgetag');
fire(pending[0].at - 1); // wieder zu früh
assert.equal(runs.length, 2);
s.stop();
assert.equal(pending.length, 0);
});
test('T2: pv ohne Einwilligung + cx: pv_total 1, pv_ref nur {google.com:1}; cx zählt nie pv_total', () => {
const { db, ev } = mk();
const t = NOW - D;
ev({ ts: t, type: 'pv', path: '/', vp: null, ref: null, country: 'DE', device: 'd' });
ev({ ts: t, type: 'cx', vp: 'd', ref: 'google.com' });
ev({ ts: t, type: 'cx', vp: 'd', ref: null });
ev({ ts: t, type: 'pv', path: '/', vp: 'd', ref: null, country: 'DE', device: 'd' });
runNightly(db, { now: NOW });
const d = '2026-10-09';
assert.equal(get(db, d, 'pv_total'), 2);
assert.equal(get(db, d, 'pv_ref', 'google.com'), 1);
assert.equal(get(db, d, 'pv_ref', ''), 2); // pv mit vp und cx ohne ref
const refs = db.prepare("SELECT key FROM daily WHERE site=? AND dim='pv_ref' ORDER BY key").all(KEY).map((r) => r.key);
assert.deepEqual(refs, ['', 'google.com']);
});
test('T2: Stats-Endpunkt refs live und aus daily, nie doppelt', async () => {
const { db, ev } = mk();
ev({ ts: NOW - D, type: 'pv', path: '/', vp: null, country: 'DE', device: 'd' });
ev({ ts: NOW - D, type: 'cx', vp: 'd', ref: 'google.com' });
const a = await admin(db);
const live = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json();
assert.equal(live.totals.pv, 1);
assert.deepEqual(live.refs, [{ key: 'google.com', count: 1 }]);
runNightly(db, { now: NOW });
const agg = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json();
assert.equal(agg.totals.pv, 1);
assert.deepEqual(agg.refs, [{ key: 'google.com', count: 1 }]);
a.close();
});