Files
KantSites/tests/StatisticsTest.php
T
kantarciandClaude Fable 5.1 160c394c3f
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
Rework forms, statistics, design and the sidebar foot in 1.15.0
Forms are an editor now: one card per field with add and remove, the
technical name derived from the label, options only for a choice, and a
new "Aussehen" section with a button label and four colours (field border
and background, button and button text) that travel to the page as one
rule per form inside the hashed style block. A live preview draws the
form with the site's default button and names the text contrast.

Statistics open with six figures (views against the same span before,
average per day, busiest day, pages, referred views, missing addresses),
a chart with grid lines and a title per bar, a weekday chart and ranked
lists with share bars. The design screen shows the palette as colour
cards with seven ready-made palettes, picks the base colours from the
palette, previews every font in itself and the typography as a sample.
The sidebar ends in an account card with role and a logout icon.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 07:59:30 +02:00

232 lines
13 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']);
// 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)]));
}
}