Files
KantSites/tests/StatisticsTest.php
T
kantarciandClaude Opus 5 99bad61179
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Statistik: Zähler statt Protokoll, deshalb ohne Einwilligung
"Wieviel wann wo wie die Seite benutzt" - naheliegend wäre ein Analysewerkzeug
gewesen, das eine Einwilligung braucht und damit genau die Besucher verliert,
die ablehnen. Also andersherum: eine Messung, die niemanden wiedererkennt.

Gespeichert wird ein Zähler je (Tag, Stunde, Pfad, Status, Verweis-Domain),
kein Datensatz je Aufruf. Keine IP-Adresse, keine Kennung, kein Cookie - auch
nicht gehasht, denn der Hash einer IP-Adresse bleibt ein Personenbezug. Ein
Test liest die Spaltenliste der Tabelle und vergleicht sie mit dieser Zusage,
damit sie beim nächsten Ausbau nicht unbemerkt fällt. Vom Verweis bleibt nur
der Host: die volle Adresse kann einen Suchbegriff oder einen Namen tragen.

Gezählt wird auf kernel.terminate, nach der Antwort - die Messung darf keine
Anfrage verlangsamen, und ein Fehler beim Zählen kostet einen Zähler, nicht
eine Seite. Nur GET auf HTML mit 200 oder 404; Verwaltung, Assets, Feed,
Sitemap und Einwilligungsseite sind ausgenommen, erkennbare Maschinen über
ihre Kennung. Das ist eine Heuristik, deshalb heißt die Zahl auf dem
Bildschirm nicht "Besucher". Erfundene Crawler-Adressen laufen gesammelt unter
(sonstige), sonst wächst die Tabelle mit jedem geprobten Pfad.

Die Diagramme zeichnet der Server als SVG mit Klassen statt Stilattributen:
kein Diagrammwerkzeug, kein Skript, und die Richtlinie bleibt streng. Die
Zahlen stehen zusätzlich in einer Tabelle, weil ein Balken für einen
Screenreader nichts ist.

Was die Zahlen nicht sagen, steht auf dem Bildschirm: Aufrufe statt Besucher,
keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Das ist die
Folge der Entscheidung, nicht eine Lücke.

Aufbewahrung: nach 60 Tagen werden die Stunden eines Tages verschmolzen, nach
425 Tagen gelöscht - gut ein Jahr, damit der Vorjahresmonat vergleichbar
bleibt. cms:stats:prune macht beides und gehört in einen Cronjob.

279 PHP-Tests / 1119 Assertions unter PHP 8.2.12 und 8.4.22, axe auf zwölf
Verwaltungsbildschirmen ohne Verstöße. Dabei kam eine PHP-8.4-Verwarnung zu
fputcsv() heraus, jetzt mit ausdrücklichem Escape-Parameter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 19:50:08 +02:00

198 lines
11 KiB
PHP

<?php
namespace App\Tests;
use App\Content\Statistics;
use App\Infrastructure\ConnectionFactory;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* The measurement, and above all the property that makes it possible without consent: there is no
* column from which a person could follow, and nothing is written per visit.
*/
final class StatisticsTest extends TestCase
{
private Connection $db;
private Statistics $stats;
private string $path;
private \DateTimeImmutable $now;
protected function setUp(): void
{
$this->path = tempnam(sys_get_temp_dir(), 'kant-stats-');
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
TestSchema::build($this->db);
$this->stats = new Statistics($this->db);
$this->now = new \DateTimeImmutable('2026-09-12 14:30:00', new \DateTimeZone('UTC'));
}
protected function tearDown(): void
{
$this->db->close();
gc_collect_cycles();
@unlink($this->path);
}
public function testTheTableHoldsNothingThatPointsAtAPerson(): void
{
$columns = array_map(
static fn (object $column): string => $column->getName(),
$this->db->createSchemaManager()->listTableColumns('stat_hit'),
);
// The whole design rests on this: counters, not records.
self::assertSame(['id', 'website_id', 'day', 'hour', 'path', 'status', 'referrer_host', 'count'], array_values($columns));
foreach (['ip', 'address', 'user', 'agent', 'session', 'cookie', 'visitor', 'hash', 'fingerprint'] as $forbidden) {
foreach ($columns as $column) {
self::assertStringNotContainsString($forbidden, $column);
}
}
}
public function testManyRequestsBecomeOneCounter(): void
{
for ($index = 0; $index < 5; ++$index) {
$this->stats->record('/leistungen', 200, '', 'kant-it.de', $this->now);
}
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM stat_hit'));
$row = $this->db->fetchAssociative('SELECT * FROM stat_hit');
self::assertSame(['2026-09-12', 14, '/leistungen', 200, '', 5], [
$row['day'], (int) $row['hour'], $row['path'], (int) $row['status'], $row['referrer_host'], (int) $row['count'],
]);
// A different hour, page, status or referrer is a different counter - and nothing else is.
$this->stats->record('/leistungen', 200, '', 'kant-it.de', $this->now->modify('+1 hour'));
$this->stats->record('/kontakt', 200, '', 'kant-it.de', $this->now);
$this->stats->record('/leistungen', 404, '', 'kant-it.de', $this->now);
$this->stats->record('/leistungen', 200, 'https://www.google.de/search?q=kant', 'kant-it.de', $this->now);
self::assertSame(5, (int) $this->db->fetchOne('SELECT COUNT(*) FROM stat_hit'));
self::assertSame('www.google.de', $this->db->fetchOne('SELECT referrer_host FROM stat_hit WHERE referrer_host <> ?', ['']));
}
/** @return iterable<string, array{string, string}> */
public static function referrers(): iterable
{
yield 'external' => ['https://www.google.de/search?q=kant+it', 'www.google.de'];
yield 'own site is not a referrer' => ['https://kant-it.de/leistungen', ''];
yield 'own site in other case' => ['https://KANT-IT.de/leistungen', ''];
yield 'direct visit' => ['', ''];
// Only the host is kept, never the address someone came from: that one can carry a search
// term, a name or a token in its query string.
yield 'no path and no query survive' => ['https://forum.example/thread/123?user=anna', 'forum.example'];
yield 'nonsense' => ['nicht-einmal-eine-adresse', ''];
yield 'host with a colon' => ['https://host:mit@doppelpunkt.example/x', 'doppelpunkt.example'];
}
#[DataProvider('referrers')]
public function testOnlyTheBareHostOfAnExternalReferrerIsKept(string $referrer, string $expected): void
{
$this->stats->record('/', 200, $referrer, 'kant-it.de', $this->now);
self::assertSame($expected, $this->db->fetchOne('SELECT referrer_host FROM stat_hit'));
}
/** @return iterable<string, array{string, int, string}> */
public static function paths(): iterable
{
yield 'a page' => ['/leistungen/webdesign', 200, '/leistungen/webdesign'];
yield 'the root' => ['/', 200, '/'];
yield 'query string is dropped' => ['/leistungen?utm_source=newsletter', 200, '/leistungen'];
yield 'a plausible 404 keeps its address' => ['/alte-seite', 404, '/alte-seite'];
// Crawlers probe thousands of invented addresses; each one would be a row of its own.
yield 'a probed 404 is bucketed' => ['/wp-admin/setup-config.php', 404, Statistics::OTHER];
yield 'a very long path is bucketed' => ['/'.str_repeat('a', 260), 200, Statistics::OTHER];
}
#[DataProvider('paths')]
public function testWhichAddressIsStored(string $path, int $status, string $expected): void
{
$this->stats->record($path, $status, '', 'kant-it.de', $this->now);
self::assertSame($expected, $this->db->fetchOne('SELECT path FROM stat_hit'));
}
/** @return iterable<string, array{array{string, string, int, string, string}, bool}> */
public static function requests(): iterable
{
yield 'a page' => [['GET', '/leistungen', 200, 'text/html; charset=UTF-8', 'Mozilla/5.0 (Windows NT 10.0)'], true];
yield 'a missing page' => [['GET', '/weg', 404, 'text/html', 'Mozilla/5.0'], true];
yield 'the administration' => [['GET', '/admin/statistik', 200, 'text/html', 'Mozilla/5.0'], false];
yield 'an image' => [['GET', '/media/abc/480', 200, 'image/webp', 'Mozilla/5.0'], false];
yield 'a stylesheet' => [['GET', '/assets/site.css', 200, 'text/css', 'Mozilla/5.0'], false];
yield 'the feed' => [['GET', '/feed.xml', 200, 'application/xml', 'Mozilla/5.0'], false];
yield 'the consent page' => [['GET', '/einwilligung', 200, 'text/html', 'Mozilla/5.0'], false];
yield 'a redirect' => [['GET', '/alt', 301, 'text/html', 'Mozilla/5.0'], false];
yield 'a form submission' => [['POST', '/kontakt', 200, 'text/html', 'Mozilla/5.0'], false];
yield 'Googlebot' => [['GET', '/', 200, 'text/html', 'Mozilla/5.0 (compatible; Googlebot/2.1)'], false];
yield 'a monitor' => [['GET', '/', 200, 'text/html', 'Uptime-Monitor/1.0'], false];
yield 'headless Chrome' => [['GET', '/', 200, 'text/html', 'HeadlessChrome/120'], false];
yield 'curl' => [['GET', '/', 200, 'text/html', 'curl/8.4.0'], false];
yield 'no user agent at all' => [['GET', '/', 200, 'text/html', ''], true];
}
#[DataProvider('requests')]
public function testWhatIsCountedAtAll(array $request, bool $expected): void
{
[$method, $path, $status, $type, $agent] = $request;
self::assertSame($expected, $this->stats->countable($method, $path, $status, $type, $agent));
}
public function testTheOverviewFillsEveryDayAndSeparatesFoundFromMissing(): void
{
$this->stats->record('/', 200, '', 'kant-it.de', $this->now->modify('-3 days'));
foreach (range(1, 4) as $ignored) {
$this->stats->record('/leistungen', 200, 'https://suche.example/x', 'kant-it.de', $this->now);
}
$this->stats->record('/gibt-es-nicht', 404, '', 'kant-it.de', $this->now);
$overview = $this->stats->overview(7, $this->now);
self::assertCount(7, $overview['days'], 'Every day of the range, including the empty ones.');
self::assertSame(5, $overview['total'], 'A 404 is not a page view.');
// Index 6 is today, index 3 is three days ago.
self::assertSame([0, 0, 0, 1, 0, 0, 4], array_column($overview['days'], 'count'));
self::assertSame(4, $overview['busiest']);
self::assertSame([['path' => '/leistungen', 'count' => 4], ['path' => '/', 'count' => 1]], $overview['pages']);
self::assertSame([['referrer_host' => 'suche.example', 'count' => 4]], $overview['referrers']);
self::assertSame([['path' => '/gibt-es-nicht', 'count' => 1]], $overview['missing']);
// The hours are summed over the whole range, so both days contribute to 14:00.
self::assertSame(5, $overview['hours'][14]['count']);
self::assertCount(24, $overview['hours']);
}
public function testOlderDaysAreCompactedAndTheOldestAreRemoved(): void
{
// Two hours of the same day, long enough ago to be compacted.
$old = $this->now->modify('-70 days');
$this->stats->record('/leistungen', 200, '', 'kant-it.de', $old);
$this->stats->record('/leistungen', 200, '', 'kant-it.de', $old->modify('+2 hours'));
// Yesterday stays untouched, and something beyond the retention goes.
$this->stats->record('/leistungen', 200, '', 'kant-it.de', $this->now->modify('-1 day'));
$this->stats->record('/leistungen', 200, '', 'kant-it.de', $this->now->modify('-500 days'));
self::assertSame(4, (int) $this->db->fetchOne('SELECT COUNT(*) FROM stat_hit'));
$result = $this->stats->prune($this->now);
self::assertSame(['compacted' => 1, 'removed' => 1], $result);
// The two hours became one row with the sum, and the total is unchanged.
$row = $this->db->fetchAssociative('SELECT hour, count FROM stat_hit WHERE day = ?', [$old->format('Y-m-d')]);
self::assertSame([-1, 2], [(int) $row['hour'], (int) $row['count']]);
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM stat_hit'));
// Pruning twice changes nothing further.
self::assertSame(['compacted' => 0, 'removed' => 0], $this->stats->prune($this->now));
// A compacted day still counts in the overview, it just has no hour any more.
$overview = $this->stats->overview(90, $this->now);
self::assertSame(3, $overview['total']);
self::assertSame(0, array_sum(array_column($overview['hours'], 'count')) - 1, 'Only yesterday is still hour precise.');
}
public function testTheExportReturnsEveryCounterOfTheRange(): void
{
$this->stats->record('/', 200, '', 'kant-it.de', $this->now);
$this->stats->record('/weg', 404, '', 'kant-it.de', $this->now);
$this->stats->record('/alt', 200, '', 'kant-it.de', $this->now->modify('-40 days'));
$rows = [...$this->stats->export(7, $this->now)];
self::assertCount(2, $rows, 'Only what lies in the range.');
self::assertSame(3, count([...$this->stats->export(90, $this->now)]));
}
}