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 */ 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 */ 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 */ 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']); // Die Summen, die eine Zahl einordnen: nichts davor, der staerkste Tag ist heute, eine // Adresse lief ins Leere, vier Aufrufe kamen ueber einen Verweis, zwei Seiten wurden gesehen. self::assertSame(0, $overview['previousTotal']); self::assertSame('2026-09-12', $overview['busiestDay']); self::assertSame(1, $overview['missingTotal']); self::assertSame(4, $overview['referred']); self::assertSame(2, $overview['pageCount']); // Der 12.09.2026 ist ein Samstag, drei Tage davor ein Mittwoch; jeder Wochentag kommt in // sieben Tagen genau einmal vor, der Schnitt ist also die Summe. self::assertCount(7, $overview['weekdays']); self::assertSame(['weekday' => 6, 'count' => 4, 'average' => 4.0], $overview['weekdays'][5]); self::assertSame(['weekday' => 3, 'count' => 1, 'average' => 1.0], $overview['weekdays'][2]); self::assertSame(0.0, $overview['weekdays'][0]['average']); } public function testTheOverviewComparesWithTheSameLengthOfTimeBefore(): void { // Three views eight to ten days ago lie in the seven days before a seven-day range. foreach ([8, 9, 10] as $daysAgo) { $this->stats->record('/', 200, '', 'kant-it.de', $this->now->modify(sprintf('-%d days', $daysAgo))); } // And one 14 days ago is already outside that comparison window. $this->stats->record('/', 200, '', 'kant-it.de', $this->now->modify('-14 days')); $this->stats->record('/', 200, '', 'kant-it.de', $this->now); $overview = $this->stats->overview(7, $this->now); self::assertSame(1, $overview['total']); self::assertSame(3, $overview['previousTotal']); // A 404 in the period before does not count as a view there either. $this->stats->record('/weg', 404, '', 'kant-it.de', $this->now->modify('-9 days')); self::assertSame(3, $this->stats->overview(7, $this->now)['previousTotal']); // Without a single view there is no busiest day to point at. self::assertNull($this->stats->overview(3, $this->now->modify('-30 days'))['busiestDay']); } 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)])); } }