207 lines
11 KiB
JavaScript
207 lines
11 KiB
JavaScript
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: Form, Serie lückenlos, heute live + 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' });
|
|
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', 'pages', 'refs', 'series', 'totals']);
|
|
assert.equal(j.series.length, 7);
|
|
assert.equal(j.series[0].day, '2026-10-04');
|
|
assert.equal(j.series[6].day, '2026-10-10');
|
|
assert.deepEqual(j.series.map((s) => s.pv), [0, 0, 0, 0, 0, 3, 1]);
|
|
assert.deepEqual(j.totals, { pv: 4, visitors: 2, contacts: 1, consentRate: 2 / 4 });
|
|
assert.deepEqual(j.pages, [{ key: '/a', count: 3 }, { key: '/b', count: 1 }]);
|
|
assert.deepEqual(j.refs[0], { key: 'g.de', count: 3 });
|
|
assert.deepEqual(j.countries, [{ key: 'DE', count: 3 }, { key: 'AT', count: 1 }]);
|
|
assert.deepEqual(j.devices, [{ key: 'd', count: 3 }, { key: 'm', count: 1 }]);
|
|
assert.deepEqual(j.contacts, [{ key: 'mail', count: 1 }]);
|
|
assert.deepEqual(j.banner, { shown: 1, accept: 2, reject: 2 });
|
|
// 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.totals.pv, 4);
|
|
a.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' });
|
|
const a = await admin(db);
|
|
assert.equal((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).totals.pv, 1);
|
|
runNightly(db, { now: NOW });
|
|
assert.equal((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).totals.pv, 1);
|
|
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 - 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);
|
|
});
|