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:
383
tests/server_test.php
Normal file
383
tests/server_test.php
Normal 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));
|
||||
});
|
||||
Reference in New Issue
Block a user