Files
wp-healthcheck/tests/lib/test-server.php
Steve Hanlon 631385721f 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.
2026-07-23 12:02:39 +01:00

214 lines
7.3 KiB
PHP

<?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;
}