// Vertragstest: echtes Portal-Client gegen die echten Collector-Server (im Prozess, In-Memory-DB). import { test, before, after } from 'node:test' import assert from 'node:assert/strict' import { createAnalyticsClient } from '../server/services/analyticsClient.js' import { createAdminServer, createPublicServer } from '../analytics/src/server.js' import { openDb } from '../analytics/src/db.js' import { computeStats } from '../analytics/src/stats.js' import { compareFrom, shortDay } from '../public/analyse-view.js' const SITE = 'abcdef0123456789abcd' const DOMAIN = 'kunde.example.com' const config = { adminToken: 'contract-admin-token', heatSecret: 'contract-heat-secret', distDir: new URL('../analytics/client/dist', import.meta.url).pathname, baseHost: 'project.webklar.com' } let db, admin, pub, client, pubUrl const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${s.address().port}`))) before(async () => { db = openDb(':memory:') const adminServer = createAdminServer({ config, db }) admin = adminServer pub = createPublicServer({ config, db }) const adminUrl = await listen(admin) pubUrl = await listen(pub) client = createAnalyticsClient({ url: adminUrl, adminToken: config.adminToken, heatSecret: config.heatSecret }) }) after(() => { admin.close(); pub.close() }) const post = (body) => fetch(`${pubUrl}/a/e`, { method: 'POST', body: JSON.stringify({ site: SITE, ...body }), headers: { 'Content-Type': 'text/plain', Origin: `https://${DOMAIN}`, 'X-Forwarded-For': '203.0.113.7', 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile Safari' }, }) test('Vertrag: putSite -> Ereignisse -> getStats -> heatToken -> deleteSite', async () => { await client.putSite(SITE, { projectId: 'p1', domains: [DOMAIN], active: true }) await post({ t: 'pv', path: '/', ref: 'google.com', vp: 'd' }) await post({ t: 'pv', path: '/', ref: '', vp: 'd' }) await post({ t: 'pv', path: '/kontakt', ref: '', vp: 'd' }) await post({ t: 'ck', path: '/', vp: 'd', x: 0.25, y: 0.5, sel: 'body:nth-of-type(1) > a:nth-of-type(1)' }) await post({ t: 'ck', path: '/', vp: 'd', x: 0.75, y: 0.1, sel: 'body:nth-of-type(1) > a:nth-of-type(2)' }) await post({ t: 'ct', path: '/', kind: 'tel' }) await post({ t: 'bn', a: 'accept' }) // Frisch gemessen: heute steht nur in `today`, der Zeitraum sind die abgeschlossenen Tage bis gestern const jetzt = await client.getStats(SITE, '7') assert.deepEqual(jetzt.today, { day: jetzt.today.day, pv: 3, visitors: 1, contacts: 1 }) assert.equal(jetzt.totals.pv, 0) assert.ok(jetzt.today.day > jetzt.to && jetzt.series.every((d) => d.day <= jetzt.to)) // Um 36 Stunden zurückdatieren: liegt immer auf einem abgeschlossenen Tag des 7-Tage-Zeitraums db.prepare('UPDATE events_raw SET ts = ts - 36 * 3600 * 1000 WHERE site = ?').run(SITE) const stats = await client.getStats(SITE, '7') assert.equal(stats.totals.pv, 3) assert.equal(stats.totals.contacts, 1) assert.equal(stats.today.pv, 0) assert.equal(stats.series.length, 7) assert.equal(stats.series.reduce((n, d) => n + d.pv, 0), 3) assert.equal(stats.range, 7) assert.equal(stats.previous.series.length, 7) assert.ok('firstDay' in stats && 'avgSec' in stats.totals) assert.ok(stats.series.every((d) => 'visitors' in d && 'contacts' in d && 'avgSec' in d)) assert.deepEqual(stats.pages, [{ key: '/', count: 2 }, { key: '/kontakt', count: 1 }]) assert.deepEqual(stats.refs, [{ key: '', count: 2 }, { key: 'google.com', count: 1 }]) assert.equal(stats.banner.accept, 1) assert.ok(Array.isArray(stats.devices) && Array.isArray(stats.countries) && Array.isArray(stats.contacts)) const token = client.heatToken({ siteKey: SITE, path: '/', vp: 'd' }) const heat = await fetch(`${pubUrl}/a/heat?token=${encodeURIComponent(token)}`) assert.equal(heat.status, 200) const { points } = await heat.json() assert.equal(points.length, 2) assert.deepEqual(points.map((p) => p.x).sort(), [0.25, 0.75]) await client.deleteSite(SITE) await assert.rejects(client.getStats(SITE, '7'), (e) => e.status === 404) assert.equal(db.prepare('SELECT COUNT(*) AS n FROM events_raw').get().n, 0) }) test('Vertrag: stopSite setzt active=0 und offSince, unbekannt zaehlt als erledigt', async () => { const KEY2 = 'fedcba9876543210fedc' await client.putSite(KEY2, { projectId: 'p2', domains: [DOMAIN], active: true }) await client.stopSite(KEY2) const row = db.prepare('SELECT active, offSince FROM sites WHERE siteKey = ?').get(KEY2) assert.equal(row.active, 0) assert.ok(row.offSince) await client.stopSite(KEY2) // zweiter Aufruf aendert nichts assert.equal(db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(KEY2).offSince, row.offSince) assert.deepEqual(await client.stopSite('00000000000000000000'), { ok: true, missing: true }) }) test('Vertrag: "Vergleich ab" des Portals ist der erste Tag, an dem der Collector den Vorzeitraum als vollständig meldet', () => { const firstDay = '2026-09-20' const evDb = openDb(':memory:') evDb.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(SITE, 'p1', '["a.de"]') evDb.prepare("INSERT INTO events_raw (site, ts, type, path, vp, country, device) VALUES (?,?,'pv','/','d','DE','d')").run(SITE, Date.UTC(2026, 8, 20, 10)) for (const range of [7, 30]) { const addDays = (day, n) => new Date(Date.UTC(+day.slice(0, 4), +day.slice(5, 7) - 1, +day.slice(8, 10) + n)).toISOString().slice(0, 10) const at = (day) => computeStats(evDb, SITE, range, Date.parse(`${day}T10:00:00Z`)) const first = addDays(firstDay, 2 * range) // "Vergleich ab" laut Portal const dayBefore = at(addDays(first, -1)) assert.equal(dayBefore.previous.complete, false, `range ${range}: am Tag davor noch nicht vollständig`) assert.deepEqual(compareFrom(dayBefore), { ok: false, from: shortDay(first) }) const onDay = at(first) assert.equal(onDay.previous.complete, true, `range ${range}: am Tag selbst vollständig`) assert.equal(onDay.previous.from, firstDay) assert.equal(compareFrom(onDay).from, '') // kein Datum mehr (der Vorzeitraum ist voll; ok bleibt false, denn es gibt nur 1 Aufruf) } })