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 '<>'; return array_key_exists($key, $row) ? $row[$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 from a dashboard table that mentions $needle — for precise assertions. */ function row_containing(string $html, string $needle): string { if (preg_match_all('#.*?#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('#
.*?
#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 $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('Next due', $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)); });