Next healthcheck due date, plus a test suite (hc-nkq)
Lets a technician record when a site should next be looked at, and surfaces that in the dashboard so it can be used for planning. Server: - migration 0002 adds healthchecks.next_due, a VARCHAR(10) 'YYYY-MM-DD' calendar date rather than a timestamp — it's a diary date with no time-of-day, and a timestamp would render as the wrong day off-server. - Kept per-session rather than on a sites table, so the history of what was scheduled when is preserved. A site's current next-due is the value on its most recent session: if the latest visit scheduled nothing, the site reads as unscheduled rather than showing the just-completed visit as overdue. - Validate::optionalDate() round-trips through createFromFormat, which rejects both '2026-2-3' and '2026-02-30' (silently rolled to March 2nd). - next_due follows the same missing-vs-null PUT contract as finished_at, and lives in its own Store::setNextDue() so finishing or reopening a session never disturbs the date and vice versa. - Dashboard shows it on the site list, the per-site session table and the session detail, flagged overdue / today / soon (within a fortnight). Plugin: - Date control on both the active and finished panels — the moment you know when to return is often wrap-up, after the report is generated. - Write-through like every other mutation. Bad input is rejected with a notice rather than silently clearing an existing date. - Included in both the Markdown and HTML reports. Tests (new — tests/, export-ignored from the plugin zip): - Hand-rolled harness, no composer/PHPUnit, in keeping with the repo. - 42 tests: plugin-side date parsing units, plus server integration tests driven over real HTTP against a temp `php -S` instance with a throwaway SQLite DB, so a real server/config.php is never touched. - Mutation-checked: dropping the array_key_exists guard on PUT fails three tests, as intended.
This commit is contained in:
213
tests/lib/test-server.php
Normal file
213
tests/lib/test-server.php
Normal file
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Boots a throwaway instance of the history server for integration tests.
|
||||
*
|
||||
* The whole server/ tree is copied to a temp directory and given its own
|
||||
* config.php + SQLite file, so a developer's real server/config.php (which is
|
||||
* gitignored and may point at a live MySQL database) is never touched or read.
|
||||
*
|
||||
* Requires pdo_sqlite. Tests that need it are skipped with a clear message if
|
||||
* the extension is missing, rather than failing misleadingly.
|
||||
*/
|
||||
final class TestServer {
|
||||
|
||||
public const API_KEY = 'test-api-key-0123456789abcdef0123456789abcdef';
|
||||
|
||||
private string $tmpDir;
|
||||
private string $baseUrl;
|
||||
private string $dbPath;
|
||||
/** @var resource|null */
|
||||
private $process = null;
|
||||
/** @var array<int, resource> */
|
||||
private array $pipes = [];
|
||||
private string $logPath;
|
||||
private string $repoRoot;
|
||||
|
||||
public static function sqliteAvailable(): bool {
|
||||
return class_exists('PDO') && in_array('sqlite', PDO::getAvailableDrivers(), true);
|
||||
}
|
||||
|
||||
// No constructor promotion: the harness stays PHP 7.4-runnable so the plugin
|
||||
// unit tests can be executed on the oldest version the plugin supports.
|
||||
public function __construct(string $repoRoot) {
|
||||
$this->repoRoot = $repoRoot;
|
||||
$this->tmpDir = sys_get_temp_dir() . '/att-hc-tests-' . getmypid() . '-' . bin2hex(random_bytes(4));
|
||||
$this->dbPath = $this->tmpDir . '/data/test.sqlite';
|
||||
$this->logPath = $this->tmpDir . '/php-server.log';
|
||||
}
|
||||
|
||||
public function baseUrl(): string { return $this->baseUrl; }
|
||||
public function dbPath(): string { return $this->dbPath; }
|
||||
|
||||
public function start(): void {
|
||||
$this->copyTree($this->repoRoot . '/server', $this->tmpDir, ['data', 'config.php']);
|
||||
@mkdir($this->tmpDir . '/data', 0777, true);
|
||||
|
||||
file_put_contents($this->tmpDir . '/config.php', "<?php\nreturn " . var_export([
|
||||
'api_key' => self::API_KEY,
|
||||
'db_dsn' => 'sqlite:' . $this->dbPath,
|
||||
'db_user' => null,
|
||||
'db_pass' => null,
|
||||
'version' => 'test',
|
||||
], true) . ";\n");
|
||||
|
||||
$port = $this->freePort();
|
||||
$this->baseUrl = 'http://127.0.0.1:' . $port;
|
||||
|
||||
$cmd = sprintf(
|
||||
'%s -S 127.0.0.1:%d -t %s %s',
|
||||
escapeshellarg(PHP_BINARY),
|
||||
$port,
|
||||
escapeshellarg($this->tmpDir . '/public'),
|
||||
escapeshellarg($this->tmpDir . '/dev-router.php')
|
||||
);
|
||||
|
||||
$this->process = proc_open(
|
||||
$cmd,
|
||||
[0 => ['file', '/dev/null', 'r'], 1 => ['file', $this->logPath, 'a'], 2 => ['file', $this->logPath, 'a']],
|
||||
$this->pipes
|
||||
);
|
||||
if (!is_resource($this->process)) {
|
||||
throw new RuntimeException('could not start php -S');
|
||||
}
|
||||
|
||||
$this->waitUntilUp();
|
||||
}
|
||||
|
||||
public function stop(): void {
|
||||
if (is_resource($this->process)) {
|
||||
proc_terminate($this->process);
|
||||
proc_close($this->process);
|
||||
$this->process = null;
|
||||
}
|
||||
$this->deleteTree($this->tmpDir);
|
||||
}
|
||||
|
||||
/** Server stdout/stderr — useful when a test fails for a non-obvious reason. */
|
||||
public function log(): string {
|
||||
return is_file($this->logPath) ? (string) file_get_contents($this->logPath) : '';
|
||||
}
|
||||
|
||||
private function waitUntilUp(): void {
|
||||
$deadline = microtime(true) + 15.0;
|
||||
while (microtime(true) < $deadline) {
|
||||
$ctx = stream_context_create(['http' => ['timeout' => 1, 'ignore_errors' => true]]);
|
||||
$body = @file_get_contents($this->baseUrl . '/', false, $ctx);
|
||||
if ($body !== false && strpos($body, '"ok"') !== false) {
|
||||
return;
|
||||
}
|
||||
usleep(100_000);
|
||||
}
|
||||
throw new RuntimeException("test server did not come up at {$this->baseUrl}\n" . $this->log());
|
||||
}
|
||||
|
||||
private function freePort(): int {
|
||||
$sock = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
|
||||
if ($sock === false) {
|
||||
throw new RuntimeException("could not find a free port: {$errstr}");
|
||||
}
|
||||
$name = (string) stream_socket_get_name($sock, false);
|
||||
fclose($sock);
|
||||
return (int) substr($name, strrpos($name, ':') + 1);
|
||||
}
|
||||
|
||||
/** @param array<int,string> $skip basenames to skip at the top level */
|
||||
private function copyTree(string $src, string $dst, array $skip = []): void {
|
||||
@mkdir($dst, 0777, true);
|
||||
foreach (scandir($src) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..' || in_array($entry, $skip, true)) continue;
|
||||
$from = $src . '/' . $entry;
|
||||
$to = $dst . '/' . $entry;
|
||||
if (is_dir($from)) {
|
||||
$this->copyTree($from, $to);
|
||||
} else {
|
||||
copy($from, $to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteTree(string $dir): void {
|
||||
if (!is_dir($dir)) return;
|
||||
foreach (scandir($dir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
$path = $dir . '/' . $entry;
|
||||
is_dir($path) ? $this->deleteTree($path) : @unlink($path);
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny HTTP client. Returns ['status'=>int, 'body'=>string, 'json'=>array|null,
|
||||
* 'headers'=>array<int,string>].
|
||||
*
|
||||
* Streams rather than curl so the suite has no extension requirements beyond
|
||||
* what the server itself needs. follow_location is off so redirects (the
|
||||
* dashboard's key → cookie handoff) are observable.
|
||||
*/
|
||||
function http_call(
|
||||
string $baseUrl,
|
||||
string $method,
|
||||
string $path,
|
||||
?array $body = null,
|
||||
?string $apiKey = TestServer::API_KEY,
|
||||
array $extraHeaders = []
|
||||
): array {
|
||||
$headers = ['Accept: application/json'];
|
||||
if ($apiKey !== null) $headers[] = 'Authorization: Bearer ' . $apiKey;
|
||||
foreach ($extraHeaders as $h) $headers[] = $h;
|
||||
|
||||
$http = [
|
||||
'method' => $method,
|
||||
'ignore_errors' => true,
|
||||
'follow_location' => 0,
|
||||
'max_redirects' => 1,
|
||||
'timeout' => 10,
|
||||
];
|
||||
if ($body !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
$http['content'] = json_encode($body, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
$http['header'] = implode("\r\n", $headers);
|
||||
|
||||
$raw = @file_get_contents($baseUrl . $path, false, stream_context_create(['http' => $http]));
|
||||
$responseHeaders = $http_response_header ?? [];
|
||||
|
||||
$status = 0;
|
||||
foreach ($responseHeaders as $h) {
|
||||
if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) $status = (int) $m[1];
|
||||
}
|
||||
|
||||
$raw = $raw === false ? '' : $raw;
|
||||
$json = json_decode($raw, true);
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'body' => $raw,
|
||||
'json' => is_array($json) ? $json : null,
|
||||
'headers' => $responseHeaders,
|
||||
];
|
||||
}
|
||||
|
||||
/** http_call() against the running test server (set up by run.php). */
|
||||
function api(
|
||||
string $method,
|
||||
string $path,
|
||||
?array $body = null,
|
||||
?string $apiKey = TestServer::API_KEY,
|
||||
array $extraHeaders = []
|
||||
): array {
|
||||
return http_call($GLOBALS['att_hc_base_url'], $method, $path, $body, $apiKey, $extraHeaders);
|
||||
}
|
||||
|
||||
/** Pull the PHP session cookie out of a response's Set-Cookie headers. */
|
||||
function session_cookie(array $response): ?string {
|
||||
foreach ($response['headers'] as $h) {
|
||||
if (stripos($h, 'Set-Cookie:') === 0 && preg_match('/(PHPSESSID=[^;]+)/i', $h, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user