Add PHP unit tests and page tests.

Run "composer install", then "composer test".
This commit is contained in:
tim
2026-09-26 03:48:09 +02:00
parent 848c447c5b
commit ed959888b4
42 changed files with 5522 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
<?php
namespace Xxx\Tests\Support;
/*
* In-memory copies of the site tables (xxx, queue, editors and
* persistent_sessions), with FakeDb handlers for the queries that the
* code sends. It knows only those queries, not SQL in general.
*
* Story rows have the columns id, title, author, email, date, story,
* hidden, img, mime, comments and editor.
*/
class SiteData {
public static array $tables = array();
public static int $lastInsertId = 0;
// The size of editors.password; NULL means that the column keeps any hash
public static ?int $passwordLength = NULL;
public static function install(array $tables = array()): void {
self::$tables = $tables + array('xxx' => array(), 'queue' => array(), 'editors' => array(), 'persistent_sessions' => array());
foreach (array('xxx', 'queue') as $table)
self::$tables[$table] = array_map(array(self::class, 'story'), self::$tables[$table]);
self::$lastInsertId = 0;
$stories = '(xxx|queue)';
FakeDb::on("/^SELECT \\* FROM $stories\\b/", array(self::class, 'selectStories'));
FakeDb::on("/^SELECT COUNT\\(\\*\\) FROM $stories\\b/", function ($sql, $params) {
return array(array('COUNT(*)' => (string)count(self::filterStories($sql, $params))));
});
FakeDb::on('/^SELECT MAX\(`date`\) FROM xxx/', function () {
$dates = array_column(self::$tables['xxx'], 'date');
return array(array('MAX(`date`)' => $dates ? max($dates) : NULL));
});
FakeDb::on("/^SELECT img, mime FROM $stories\\b/i", function ($sql, $params) {
$rows = self::filterStories($sql, $params);
return array_map(function ($row) { return array('img' => $row['img'], 'mime' => $row['mime']); }, array_slice($rows, 0, 1));
});
FakeDb::on("/^INSERT INTO $stories SET /", array(self::class, 'insertStory'));
FakeDb::on("/^UPDATE $stories SET /", array(self::class, 'updateStory'));
FakeDb::on("/^DELETE FROM $stories WHERE `id` = \\? LIMIT 1/", function ($sql, $params) {
$table = self::tableOf($sql);
self::$tables[$table] = array_values(array_filter(self::$tables[$table], function ($row) use ($params) {
return (int)$row['id'] !== (int)$params[0];
}));
return TRUE;
});
FakeDb::on('/^SELECT LAST_INSERT_ID\(\)/', function () {
return array(array('LAST_INSERT_ID()' => (string)self::$lastInsertId));
});
FakeDb::on('/^SELECT (\*|username|password) FROM editors WHERE username = \?/', function ($sql, $params) {
return array_slice(array_values(array_filter(self::$tables['editors'], function ($row) use ($params) {
return $row['username'] === $params[0];
})), 0, 1);
});
FakeDb::on('/^UPDATE editors SET password = \? WHERE username = \?/', function ($sql, $params) {
foreach (self::$tables['editors'] as &$row) {
if ($row['username'] === $params[1])
$row['password'] = self::cut($params[0]);
}
return TRUE;
});
FakeDb::on('/^INSERT INTO editors SET `username` = \?, `password` = \?, `email` = \?/', function ($sql, $params) {
self::$tables['editors'][] = array('username' => $params[0], 'password' => self::cut($params[1]), 'email' => $params[2]);
return TRUE;
});
FakeDb::on('/^DELETE FROM editors WHERE username = \?/', function ($sql, $params) {
self::$tables['editors'] = array_values(array_filter(self::$tables['editors'], function ($row) use ($params) {
return $row['username'] !== $params[0];
}));
return TRUE;
});
FakeDb::on('/^SELECT \* FROM persistent_sessions WHERE username = \? AND session = \?/', function ($sql, $params) {
return array_slice(array_values(array_filter(self::$tables['persistent_sessions'], function ($row) use ($params) {
return $row['username'] === $params[0] && $row['session'] === $params[1];
})), 0, 1);
});
FakeDb::on('/^INSERT INTO persistent_sessions \(username, session\) VALUES \(\?, \?\)/', function ($sql, $params) {
self::$tables['persistent_sessions'][] = array('username' => $params[0], 'session' => $params[1], 'created' => gmdate('Y-m-d H:i:s'));
return TRUE;
});
FakeDb::on('/^DELETE FROM persistent_sessions WHERE username = \? AND session = \?/', function ($sql, $params) {
self::$tables['persistent_sessions'] = array_values(array_filter(self::$tables['persistent_sessions'], function ($row) use ($params) {
return !($row['username'] === $params[0] && $row['session'] === $params[1]);
}));
return TRUE;
});
FakeDb::on('/^DELETE FROM persistent_sessions\s+WHERE created < DATE_SUB/', function () {
$limit = gmdate('Y-m-d H:i:s', strtotime('-1 year'));
self::$tables['persistent_sessions'] = array_values(array_filter(self::$tables['persistent_sessions'], function ($row) use ($limit) {
return strlen($row['session']) === 64 && ($row['created'] ?? $limit) >= $limit;
}));
return TRUE;
});
}
// A story row with every column, from the columns given
public static function story(array $row): array {
static $next = 1;
$row += array('id' => $next, 'title' => 'Title', 'author' => 'Author', 'email' => '',
'date' => '2020-01-01 10:00:00', 'story' => '<p>Story.</p>', 'hidden' => '0',
'img' => NULL, 'mime' => NULL, 'comments' => '', 'editor' => 'original author');
$next = max($next, (int)$row['id']) + 1;
$row['id'] = (string)$row['id'];
return $row;
}
public static function tableOf(string $sql): string {
preg_match('/\b(FROM|INTO|UPDATE)\s+`?(xxx|queue)\b/', $sql, $match);
return $match[2];
}
// The rows of the story table in $sql that its WHERE clause selects
public static function filterStories(string $sql, array $params): array {
$rows = self::$tables[self::tableOf($sql)];
if (stripos($sql, 'hidden IS NOT TRUE') !== FALSE)
$rows = array_filter($rows, function ($row) { return !$row['hidden']; });
if (preg_match('/\bid = (\d+)/i', $sql, $match))
$rows = array_filter($rows, function ($row) use ($match) { return (int)$row['id'] === (int)$match[1]; });
else if (preg_match('/\bid = \?/i', $sql))
$rows = array_filter($rows, function ($row) use ($params) { return (int)$row['id'] === (int)$params[0]; });
if (preg_match('/title LIKE \?/', $sql)) {
$term = strtolower(trim($params[0], '%'));
$rows = array_filter($rows, function ($row) use ($term) {
foreach (array('title', 'author', 'date', 'story') as $column) {
if (str_contains(strtolower((string)$row[$column]), $term))
return TRUE;
}
return FALSE;
});
}
return array_values($rows);
}
public static function selectStories(string $sql, array $params): array {
$rows = self::filterStories($sql, $params);
if (preg_match('/ORDER BY (\w+) (asc|desc)/i', $sql, $match)) {
$column = $match[1];
usort($rows, function ($a, $b) use ($column) { return strcmp((string)$a[$column], (string)$b[$column]); });
if (strtolower($match[2]) === 'desc')
$rows = array_reverse($rows);
}
if (preg_match('/LIMIT (\d+)\s*,\s*(\d+)/', $sql, $match))
$rows = array_slice($rows, (int)$match[1], (int)$match[2]);
return $rows;
}
// The column => value pairs of "SET `a` = ?, `b` = ?"
private static function assignments(string $sql, array $params): array {
preg_match_all('/`(\w+)` = \?/', explode(' WHERE ', $sql)[0], $matches);
return array_combine($matches[1], array_slice($params, 0, count($matches[1])));
}
public static function insertStory(string $sql, array $params) {
$ids = array_merge(array_column(self::$tables['xxx'], 'id'), array_column(self::$tables['queue'], 'id'), array(0));
$row = self::story(array('id' => max(array_map('intval', $ids)) + 1) + self::assignments($sql, $params));
self::$tables[self::tableOf($sql)][] = $row;
self::$lastInsertId = (int)$row['id'];
return TRUE;
}
public static function updateStory(string $sql, array $params) {
$id = (int)end($params);
$table = self::tableOf($sql);
foreach (self::$tables[$table] as &$row) {
if ((int)$row['id'] === $id)
$row = array_merge($row, self::assignments($sql, $params));
}
return TRUE;
}
private static function cut(string $value): string {
return self::$passwordLength === NULL ? $value : substr($value, 0, self::$passwordLength);
}
// The rows of a story table, found by id
public static function find(string $table, $id): ?array {
foreach (self::$tables[$table] as $row) {
if ((int)$row['id'] === (int)$id)
return $row;
}
return NULL;
}
}