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:
2026-07-23 12:02:39 +01:00
parent f551b64e2e
commit 631385721f
17 changed files with 1116 additions and 12 deletions

109
tests/lib/harness.php Normal file
View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
/**
* Minimal test harness — no composer, no PHPUnit, matching the rest of the repo.
*
* Usage:
* test('description', function () { assert_same($expected, $actual, 'why'); });
*
* Failures are recorded and reported at the end; the process exits non-zero if
* anything failed, so this is usable as a CI/pre-push gate.
*/
final class TestRun {
/** @var array<int, array{name:string, error:string}> */
public static array $failures = [];
public static int $passed = 0;
public static int $assertions = 0;
public static string $group = '';
}
final class AssertionFailed extends RuntimeException {}
function group(string $name): void {
TestRun::$group = $name;
echo "\n\033[1m" . $name . "\033[0m\n";
}
function test(string $name, callable $fn): void {
try {
$fn();
TestRun::$passed++;
echo " \033[32m✓\033[0m " . $name . "\n";
} catch (AssertionFailed $e) {
TestRun::$failures[] = ['name' => TestRun::$group . ' ' . $name, 'error' => $e->getMessage()];
echo " \033[31m✗ " . $name . "\033[0m\n " . str_replace("\n", "\n ", $e->getMessage()) . "\n";
} catch (Throwable $e) {
$where = basename($e->getFile()) . ':' . $e->getLine();
TestRun::$failures[] = ['name' => TestRun::$group . ' ' . $name, 'error' => get_class($e) . ': ' . $e->getMessage() . ' @ ' . $where];
echo " \033[31m✗ " . $name . "\033[0m\n " . get_class($e) . ': ' . $e->getMessage() . ' @ ' . $where . "\n";
}
}
function assert_same($expected, $actual, string $message = ''): void {
TestRun::$assertions++;
if ($expected !== $actual) {
throw new AssertionFailed(
($message !== '' ? $message . "\n" : '')
. 'expected: ' . compact_export($expected) . "\n"
. 'actual: ' . compact_export($actual)
);
}
}
function assert_true($actual, string $message = ''): void {
assert_same(true, $actual, $message !== '' ? $message : 'expected true');
}
function assert_null($actual, string $message = ''): void {
assert_same(null, $actual, $message !== '' ? $message : 'expected null');
}
function assert_contains(string $needle, string $haystack, string $message = ''): void {
TestRun::$assertions++;
if (strpos($haystack, $needle) === false) {
throw new AssertionFailed(
($message !== '' ? $message . "\n" : '')
. 'expected to find: ' . $needle . "\n"
. 'in: ' . truncate($haystack, 600)
);
}
}
function assert_not_contains(string $needle, string $haystack, string $message = ''): void {
TestRun::$assertions++;
if (strpos($haystack, $needle) !== false) {
throw new AssertionFailed(
($message !== '' ? $message . "\n" : '')
. 'did NOT expect to find: ' . $needle . "\n"
. 'in: ' . truncate($haystack, 600)
);
}
}
function compact_export($value): string {
if (is_string($value)) return "'" . $value . "'";
if ($value === null) return 'null';
if (is_bool($value)) return $value ? 'true' : 'false';
if (is_array($value)) return truncate(json_encode($value, JSON_UNESCAPED_SLASHES), 400);
return (string) $value;
}
function truncate(string $s, int $max): string {
return strlen($s) <= $max ? $s : substr($s, 0, $max) . '… (' . strlen($s) . ' bytes)';
}
function report_and_exit(): void {
$failed = count(TestRun::$failures);
echo "\n" . str_repeat('─', 60) . "\n";
if ($failed === 0) {
echo "\033[32mPASS\033[0m — " . TestRun::$passed . ' tests, ' . TestRun::$assertions . " assertions\n";
exit(0);
}
echo "\033[31mFAIL\033[0m — " . $failed . ' failed, ' . TestRun::$passed . " passed\n\n";
foreach (TestRun::$failures as $f) {
echo "" . $f['name'] . "\n " . str_replace("\n", "\n ", $f['error']) . "\n\n";
}
exit(1);
}

213
tests/lib/test-server.php Normal file
View 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;
}

78
tests/plugin_test.php Normal file
View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* Plugin-side unit tests.
*
* These cover the pure static helpers on ATT_HC_Session, which is the only part
* of the plugin that can be exercised without a WordPress runtime — the class
* body itself calls no WP functions at load time, so defining ABSPATH is enough
* to require it.
*/
if (!defined('ABSPATH')) {
define('ABSPATH', __DIR__ . '/');
}
require_once dirname(__DIR__) . '/includes/class-att-hc-session.php';
group('plugin: ATT_HC_Session::sanitise_due_date()');
test('accepts a well-formed date', function () {
assert_same('2026-10-01', ATT_HC_Session::sanitise_due_date('2026-10-01'));
});
test('trims surrounding whitespace', function () {
assert_same('2026-10-01', ATT_HC_Session::sanitise_due_date(" 2026-10-01\n"));
});
test('accepts a real leap day', function () {
assert_same('2028-02-29', ATT_HC_Session::sanitise_due_date('2028-02-29'));
});
test('rejects an empty string', function () {
assert_null(ATT_HC_Session::sanitise_due_date(''));
assert_null(ATT_HC_Session::sanitise_due_date(' '));
});
test('rejects unpadded components rather than silently normalising them', function () {
// '2026-2-3' parses, but re-formats to '2026-02-03'. Accepting it would mean
// the stored value differs from what the user believes they typed.
assert_null(ATT_HC_Session::sanitise_due_date('2026-2-3'));
});
test('rejects a date that does not exist', function () {
// createFromFormat would roll this forward to 2026-03-02 without complaint.
assert_null(ATT_HC_Session::sanitise_due_date('2026-02-30'));
assert_null(ATT_HC_Session::sanitise_due_date('2027-02-29'), 'not a leap year');
assert_null(ATT_HC_Session::sanitise_due_date('2026-13-01'), 'month 13');
assert_null(ATT_HC_Session::sanitise_due_date('2026-00-10'), 'month 0');
});
test('rejects other date formats', function () {
assert_null(ATT_HC_Session::sanitise_due_date('01/10/2026'));
assert_null(ATT_HC_Session::sanitise_due_date('1 Oct 2026'));
assert_null(ATT_HC_Session::sanitise_due_date('2026-10-01T00:00:00Z'));
assert_null(ATT_HC_Session::sanitise_due_date('20261001'));
});
test('rejects free text', function () {
assert_null(ATT_HC_Session::sanitise_due_date('next tuesday'));
assert_null(ATT_HC_Session::sanitise_due_date('soon'));
assert_null(ATT_HC_Session::sanitise_due_date('<script>alert(1)</script>'));
});
group('plugin: ATT_HC_Session::normalise_site_url()');
test('strips scheme, www and trailing slash', function () {
assert_same('example.com', ATT_HC_Session::normalise_site_url('https://www.example.com/'));
assert_same('example.com', ATT_HC_Session::normalise_site_url('http://example.com'));
});
test('lowercases the host', function () {
assert_same('example.com', ATT_HC_Session::normalise_site_url('https://EXAMPLE.com'));
});
test('keeps a subdirectory install path', function () {
assert_same('example.com/blog', ATT_HC_Session::normalise_site_url('https://example.com/blog/'));
});

67
tests/run.php Normal file
View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* Test entry point.
*
* php tests/run.php # everything
* php tests/run.php plugin # plugin unit tests only (runs on PHP 7.4+)
* php tests/run.php server # server integration tests only (needs PHP 8.1+)
*
* Exits non-zero if anything fails, so it works as a pre-push gate.
*/
$root = dirname(__DIR__);
require __DIR__ . '/lib/harness.php';
require __DIR__ . '/lib/test-server.php';
$suite = $argv[1] ?? 'all';
if (!in_array($suite, ['all', 'plugin', 'server'], true)) {
fwrite(STDERR, "usage: php tests/run.php [all|plugin|server]\n");
exit(2);
}
if ($suite === 'all' || $suite === 'plugin') {
require __DIR__ . '/plugin_test.php';
}
if ($suite === 'all' || $suite === 'server') {
if (!TestServer::sqliteAvailable()) {
echo "\n\033[33mSKIPPED\033[0m server tests — this PHP build has no pdo_sqlite.\n"
. " Install php-sqlite3 (or run the suite on a box that has it).\n";
} elseif (PHP_VERSION_ID < 80100) {
echo "\n\033[33mSKIPPED\033[0m server tests — the server needs PHP 8.1+, this is " . PHP_VERSION . ".\n";
} else {
$server = new TestServer($root);
// Shutdown hook as well as the finally below: a fatal error skips
// finally, and an orphaned `php -S` would hold its port.
register_shutdown_function(static function () use ($server) { $server->stop(); });
$server->start();
$GLOBALS['att_hc_base_url'] = $server->baseUrl();
$GLOBALS['att_hc_db_path'] = $server->dbPath();
try {
require __DIR__ . '/server_test.php';
} finally {
// Only on failure, and only the interesting lines — the built-in
// server logs two access lines per request, which buries anything real.
if (count(TestRun::$failures) > 0) {
$lines = array_filter(
explode("\n", $server->log()),
static function (string $l): bool {
return trim($l) !== '' && strpos($l, 'Accepted') === false && strpos($l, 'Closing') === false;
}
);
if ($lines) {
echo "\n\033[33mtest server log:\033[0m\n" . implode("\n", array_slice($lines, -40)) . "\n";
}
}
$server->stop();
}
}
}
report_and_exit();

383
tests/server_test.php Normal file
View File

@@ -0,0 +1,383 @@
<?php
declare(strict_types=1);
/**
* End-to-end tests for the history server, driven over real HTTP against a
* throwaway instance (see lib/test-server.php).
*
* Focus is the next_due feature plus the surfaces it touches: create/update
* validation, the missing-vs-null PUT contract, the dashboard views, and the
* migration that adds the column.
*/
// ── Fixtures ─────────────────────────────────────────────────────────────────
/** Monotonic started_at values — (site_key, started_at) is UNIQUE. */
function next_started_at(): int {
static $t = 1750000000;
return $t += 3600;
}
function unique_site(string $label): string {
static $n = 0;
return $label . '-' . (++$n) . '.example.com';
}
/** Create a healthcheck. Returns ['id' => string, 'response' => array]. */
function create_hc(string $siteKey, array $overrides = []): array {
static $n = 0;
$payload = array_merge([
// Dotted, like the plugin's uniqid() ids — also exercises the dev-router
// path handling that plain `php -S -t public` gets wrong.
'id' => 'att_hc_test_' . (++$n) . '.' . bin2hex(random_bytes(3)),
'site_key' => $siteKey,
'started_at' => next_started_at(),
'reporting_url' => 'https://' . $siteKey,
'technician_id' => 1,
'wp_version' => '6.6',
'php_version' => '8.2',
], $overrides);
$res = api('POST', '/healthchecks', $payload);
return ['id' => (string) $payload['id'], 'response' => $res];
}
/**
* Read a field, distinguishing "present and null" from "absent".
*
* `$row['k'] ?? null` collapses those two cases, which would make every
* "the date was cleared" assertion pass even if the field vanished entirely.
*/
function field(?array $row, string $key) {
if ($row === null) return '<<no json>>';
return array_key_exists($key, $row) ? $row[$key] : '<<missing: ' . $key . '>>';
}
function get_hc(string $id): array {
return api('GET', '/healthchecks/' . rawurlencode($id));
}
/** Authenticated dashboard GET (key → cookie handshake done once). */
function dash(string $path): array {
static $cookie = null;
if ($cookie === null) {
$login = api('GET', '/dashboard?key=' . rawurlencode(TestServer::API_KEY), null, null);
$cookie = session_cookie($login) ?? '';
}
return api('GET', $path, null, null, ['Cookie: ' . $cookie]);
}
/** The <tr> from a dashboard table that mentions $needle — for precise assertions. */
function row_containing(string $html, string $needle): string {
if (preg_match_all('#<tr>.*?</tr>#s', $html, $m)) {
foreach ($m[0] as $row) {
if (strpos($row, htmlspecialchars($needle)) !== false) return $row;
}
}
return '';
}
/**
* The session-meta block of a session detail page.
*
* Assertions have to be scoped to it: the page's inline stylesheet mentions
* every status and due-state class name, so a whole-page assert_not_contains()
* can never fail.
*/
function session_meta(string $html): string {
return preg_match('#<div class="session-meta">.*?</div>#s', $html, $m) ? $m[0] : '';
}
function days_from_today(int $days): string {
return date('Y-m-d', strtotime($days . ' days'));
}
// ── Health + auth ────────────────────────────────────────────────────────────
group('server: health + auth');
test('GET / is public and reports service identity', function () {
$r = api('GET', '/', null, null);
assert_same(200, $r['status']);
assert_same(true, $r['json']['ok'] ?? null);
assert_same('att-site-healthcheck-server', $r['json']['service'] ?? null);
});
test('writes require a bearer token', function () {
$r = api('POST', '/healthchecks', ['id' => 'nope'], null);
assert_same(401, $r['status']);
});
test('a wrong bearer token is rejected', function () {
$r = api('GET', '/sites', null, 'not-the-key');
assert_same(401, $r['status']);
});
// ── next_due at creation ─────────────────────────────────────────────────────
group('server: next_due on create');
test('a healthcheck can be created with a next-due date', function () {
$site = unique_site('create-with-date');
$hc = create_hc($site, ['next_due' => '2026-10-01']);
assert_same(201, $hc['response']['status'], 'create should succeed');
$got = get_hc($hc['id']);
assert_same(200, $got['status']);
assert_same('2026-10-01', field($got['json'], 'next_due'));
});
test('next_due defaults to null when omitted', function () {
$hc = create_hc(unique_site('create-no-date'));
$got = get_hc($hc['id']);
assert_same(201, $hc['response']['status']);
assert_null(field($got['json'], 'next_due'), 'column should exist and be null');
});
test('an empty next_due string is stored as null', function () {
$hc = create_hc(unique_site('create-empty-date'), ['next_due' => '']);
assert_same(201, $hc['response']['status']);
assert_null(field(get_hc($hc['id'])['json'], 'next_due'));
});
test('an impossible date is rejected and nothing is created', function () {
$hc = create_hc(unique_site('create-bad-date'), ['next_due' => '2026-02-30']);
assert_same(422, $hc['response']['status']);
assert_same('invalid', $hc['response']['json']['code'] ?? null);
assert_same(404, get_hc($hc['id'])['status'], 'rejected create must not persist');
});
test('free text and other formats are rejected', function () {
foreach (['soon', '01/10/2026', '2026-2-3', '2026-13-01', '20261001'] as $bad) {
$hc = create_hc(unique_site('create-bad'), ['next_due' => $bad]);
assert_same(422, $hc['response']['status'], 'should reject: ' . $bad);
}
});
test('a non-string next_due is rejected', function () {
$hc = create_hc(unique_site('create-int-date'), ['next_due' => 20261001]);
assert_same(422, $hc['response']['status']);
});
// ── next_due updates ─────────────────────────────────────────────────────────
group('server: next_due updates');
test('PUT sets a next-due date', function () {
$hc = create_hc(unique_site('update-set'));
$r = api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['next_due' => '2027-01-15']);
assert_same(200, $r['status']);
assert_same('2027-01-15', field(get_hc($hc['id'])['json'], 'next_due'));
});
test('PUT with no next_due key leaves the date untouched', function () {
// The missing-vs-null contract: an unrelated PUT must not wipe the schedule.
$hc = create_hc(unique_site('update-noop'), ['next_due' => '2027-03-03']);
$r = api('PUT', '/healthchecks/' . rawurlencode($hc['id']), []);
assert_same(200, $r['status']);
assert_same('2027-03-03', field(get_hc($hc['id'])['json'], 'next_due'));
});
test('finishing a session preserves the date', function () {
$hc = create_hc(unique_site('update-finish'), ['next_due' => '2027-04-04']);
$now = 1750500000;
$r = api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['finished_at' => $now]);
assert_same(200, $r['status']);
$got = get_hc($hc['id'])['json'];
assert_same($now, (int) ($got['finished_at'] ?? 0), 'finished_at should be set');
assert_same('2027-04-04', field($got, 'next_due'), 'finishing must not clear the date');
});
test('reopening a session preserves the date', function () {
$hc = create_hc(unique_site('update-reopen'), ['next_due' => '2027-05-05']);
api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['finished_at' => 1750600000]);
api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['finished_at' => null]);
$got = get_hc($hc['id'])['json'];
assert_null(field($got, 'finished_at'), 'should be reopened');
assert_same('2027-05-05', field($got, 'next_due'));
});
test('setting the date does not disturb finished_at', function () {
$hc = create_hc(unique_site('update-date-only'));
api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['finished_at' => 1750700000]);
api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['next_due' => '2027-06-06']);
$got = get_hc($hc['id'])['json'];
assert_same(1750700000, (int) ($got['finished_at'] ?? 0));
assert_same('2027-06-06', field($got, 'next_due'));
});
test('an explicit null clears the date', function () {
$hc = create_hc(unique_site('update-clear-null'), ['next_due' => '2027-07-07']);
api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['next_due' => null]);
assert_null(field(get_hc($hc['id'])['json'], 'next_due'));
});
test('an empty string clears the date', function () {
$hc = create_hc(unique_site('update-clear-empty'), ['next_due' => '2027-08-08']);
api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['next_due' => '']);
assert_null(field(get_hc($hc['id'])['json'], 'next_due'));
});
test('an invalid update is rejected and leaves the existing date intact', function () {
$hc = create_hc(unique_site('update-bad'), ['next_due' => '2027-09-09']);
$r = api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['next_due' => 'whenever']);
assert_same(422, $r['status']);
assert_same('2027-09-09', field(get_hc($hc['id'])['json'], 'next_due'), 'bad input must not clobber');
});
test('updating an unknown healthcheck is a 404', function () {
$r = api('PUT', '/healthchecks/does-not-exist', ['next_due' => '2027-10-10']);
assert_same(404, $r['status']);
});
// ── Listing + step regression ────────────────────────────────────────────────
group('server: listing and steps');
test('the list endpoint exposes next_due', function () {
$site = unique_site('list-dates');
create_hc($site, ['next_due' => '2027-11-11']);
$r = api('GET', '/healthchecks?' . http_build_query(['site_key' => $site, 'limit' => 10]));
assert_same(200, $r['status']);
$rows = $r['json']['healthchecks'] ?? [];
assert_same(1, count($rows));
assert_same('2027-11-11', field($rows[0], 'next_due'));
});
test('step upsert and readback still work', function () {
// Regression guard: insertHealthcheck gained a column, steps must be unaffected.
$site = unique_site('steps');
$hc = create_hc($site, ['next_due' => '2027-12-12']);
$r = api('PUT', '/healthchecks/' . rawurlencode($hc['id']) . '/steps/backup', [
'status' => 'done',
'notes' => 'Full backup taken',
'reporting_url' => 'https://' . $site,
]);
assert_same(200, $r['status']);
$got = get_hc($hc['id'])['json'];
$steps = $got['steps'] ?? [];
assert_same(1, count($steps));
assert_same('backup', $steps[0]['step_id'] ?? null);
assert_same('done', $steps[0]['status'] ?? null);
assert_same('2027-12-12', field($got, 'next_due'), 'a step write must not disturb the date');
});
// ── Dashboard ────────────────────────────────────────────────────────────────
group('server: dashboard');
test('the dashboard requires the key', function () {
$r = api('GET', '/dashboard', null, null);
assert_contains('API key', $r['body'], 'should render the login form');
assert_not_contains('All sites <span', $r['body'], 'must not render the site list');
});
test('the site list shows the scheduled date', function () {
$site = unique_site('dash-scheduled');
$due = days_from_today(90);
create_hc($site, ['next_due' => $due]);
$row = row_containing(dash('/dashboard')['body'], $site);
assert_contains($due, $row, 'the date should appear on this site row');
assert_contains('due-ok', $row, 'a distant date is not urgent');
assert_not_contains('overdue', $row);
});
test('a past date is flagged overdue', function () {
$site = unique_site('dash-overdue');
create_hc($site, ['next_due' => days_from_today(-10)]);
$row = row_containing(dash('/dashboard')['body'], $site);
assert_contains('due-overdue', $row);
assert_contains('overdue', $row);
});
test('a date within a fortnight is flagged as soon', function () {
$site = unique_site('dash-soon');
create_hc($site, ['next_due' => days_from_today(5)]);
$row = row_containing(dash('/dashboard')['body'], $site);
assert_contains('due-soon', $row);
assert_not_contains('overdue', $row);
});
test('a site with nothing scheduled shows a dash', function () {
$site = unique_site('dash-none');
create_hc($site);
$row = row_containing(dash('/dashboard')['body'], $site);
assert_contains('—', $row);
assert_not_contains('due-ok', $row);
assert_not_contains('due-overdue', $row);
});
test('the newest session wins — a stale date is not carried forward', function () {
// The visit that was scheduled has since happened; the site should read as
// "nothing scheduled", not as permanently overdue.
$site = unique_site('dash-superseded');
create_hc($site, ['next_due' => days_from_today(-30)]); // older session
create_hc($site); // newer, no date
$row = row_containing(dash('/dashboard')['body'], $site);
assert_not_contains('overdue', $row, 'the completed visit must not still show as due');
assert_not_contains(days_from_today(-30), $row);
assert_contains('—', $row);
});
test('the per-site session table shows each session\'s date', function () {
$site = unique_site('dash-site-detail');
$due = days_from_today(45);
create_hc($site, ['next_due' => $due]);
$body = dash('/dashboard?site=' . rawurlencode($site))['body'];
assert_contains('<th>Next due</th>', $body);
assert_contains($due, $body);
});
test('the session detail view shows the date', function () {
$site = unique_site('dash-session-detail');
$due = days_from_today(60);
$hc = create_hc($site, ['next_due' => $due]);
api('PUT', '/healthchecks/' . rawurlencode($hc['id']) . '/steps/backup', [
'status' => 'done',
'notes' => 'ok',
'reporting_url' => 'https://' . $site,
]);
$meta = session_meta(dash('/dashboard?site=' . rawurlencode($site) . '&hc=' . rawurlencode($hc['id']))['body']);
assert_contains('Next due:', $meta);
assert_contains($due, $meta);
assert_contains('due-ok', $meta, 'a date two months out is not urgent');
});
test('a session with no date shows a dash in the detail view', function () {
$site = unique_site('dash-session-nodate');
$hc = create_hc($site);
$meta = session_meta(dash('/dashboard?site=' . rawurlencode($site) . '&hc=' . rawurlencode($hc['id']))['body']);
assert_contains('Next due:', $meta);
assert_contains('—', $meta);
assert_not_contains('due-overdue', $meta);
assert_not_contains('due-ok', $meta);
});
// ── Migrations ───────────────────────────────────────────────────────────────
group('server: migrations');
test('both migrations are recorded exactly once', function () {
$pdo = new PDO('sqlite:' . $GLOBALS['att_hc_db_path'], null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$applied = $pdo->query('SELECT filename FROM migrations ORDER BY filename')->fetchAll(PDO::FETCH_COLUMN);
assert_same(['0001_initial.sql', '0002_next_due.sql'], $applied);
});
test('the next_due column exists on healthchecks', function () {
$pdo = new PDO('sqlite:' . $GLOBALS['att_hc_db_path'], null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$cols = array_column($pdo->query('PRAGMA table_info(healthchecks)')->fetchAll(PDO::FETCH_ASSOC), 'name');
assert_true(in_array('next_due', $cols, true), 'columns: ' . implode(', ', $cols));
});