CluPilotCloud/tests/Feature/StatusHistoryTest.php

208 lines
9.0 KiB
PHP

<?php
use App\Models\Incident;
use App\Models\Instance;
use App\Models\StatusDay;
use Illuminate\Support\Carbon;
/**
* The written record behind the status page.
*
* A status page is the one page in a product whose entire value is that people
* believe it. Every test here is about a way it could quietly lie: a day nobody
* measured drawn as a good day, an uptime figure that averages a four-sample
* day against a three-hundred-sample day, a resolved incident quietly vanishing
* the morning after, an unpublished draft appearing in public.
*/
it('never draws a day nobody measured as a good day', function () {
// The single most tempting shortcut on a page like this: no row means
// nothing was wrong. It means nothing was looked at.
StatusDay::query()->create([
'day' => Carbon::now()->local()->subDay()->toDateString(),
'component' => 'portal',
'samples' => 288, 'operational' => 288, 'degraded' => 0, 'down' => 0, 'unknown' => 0,
]);
$history = $this->get('/status')->viewData('history')['portal']['days'];
$yesterday = collect($history)->firstWhere(fn ($d) => $d['date']->isYesterday());
$ninetyDaysAgo = collect($history)->first();
expect($yesterday['state'])->toBe('operational')
->and($ninetyDaysAgo['state'])->toBeNull()
->and($ninetyDaysAgo['uptime'])->toBeNull();
});
it('weights the uptime figure by samples, not by days', function () {
// A day the sampler only managed four runs on must not count as much as a
// full day. Averaging the daily percentages is the version of this that
// looks right and reports 50 % for one bad hour in three months.
$day = fn (int $ago, int $samples, int $down) => StatusDay::query()->create([
'day' => Carbon::now()->local()->subDays($ago)->toDateString(),
'component' => 'portal',
'samples' => $samples, 'operational' => $samples - $down,
'degraded' => 0, 'down' => $down, 'unknown' => 0,
]);
$day(1, 288, 0);
$day(2, 4, 2); // half of a tiny day was down
$uptime = $this->get('/status')->viewData('history')['portal']['uptime'];
// 290 up out of 292 measured, not the mean of 100 % and 50 % — which would
// report 75 % for two minutes of downtime in a quarter.
expect($uptime)->toBe(round(290 / 292 * 100, 2))
->and($uptime)->toBeGreaterThan(99.0);
});
it('counts a degraded sample as reachable but still colours the day', function () {
// Folding "slower than usual" into downtime reports a bad morning as an
// outage; ignoring it entirely loses the only trace of it on the page.
$row = StatusDay::query()->create([
'day' => Carbon::now()->local()->toDateString(),
'component' => 'portal',
'samples' => 100, 'operational' => 90, 'degraded' => 10, 'down' => 0, 'unknown' => 0,
]);
expect($row->uptimePercent())->toBe(100.0)
->and($row->worst())->toBe('degraded');
});
it('leaves unknown samples out of the arithmetic entirely', function () {
// "We could not tell" is not uptime and it is not downtime. Counting it as
// either invents a measurement that was never taken.
$row = StatusDay::query()->create([
'day' => Carbon::now()->local()->toDateString(),
'component' => 'instances',
'samples' => 100, 'operational' => 50, 'degraded' => 0, 'down' => 0, 'unknown' => 50,
]);
expect($row->uptimePercent())->toBe(100.0);
});
it('adds to today rather than replacing it, however often the sampler runs', function () {
Instance::query()->delete();
$this->artisan('clupilot:sample-status')->assertSuccessful();
$this->artisan('clupilot:sample-status')->assertSuccessful();
$this->artisan('clupilot:sample-status')->assertSuccessful();
$row = StatusDay::query()->where('component', 'portal')->firstOrFail();
// Three samples, not one overwritten three times — the counters are what
// the ninety-day figure is computed from.
expect($row->samples)->toBe(3)->and($row->operational)->toBe(3);
});
it('reports a duration forwards in time', function () {
// It printed "Dauer -86 Minuten" on the public page: diffInMinutes is
// signed and the operands were the wrong way round.
$incident = Incident::query()->create([
'title' => 'x', 'impact' => 'down', 'components' => ['portal'],
'started_at' => Carbon::parse('2026-07-15 19:42'),
'resolved_at' => Carbon::parse('2026-07-15 21:08'),
'published_at' => now(),
]);
expect($incident->durationMinutes())->toBe(86);
});
it('keeps a resolved incident on the page after it is over', function () {
// The whole point. "Alle Dienste in Betrieb" the morning after is true and
// useless to somebody who was locked out the evening before.
$incident = Incident::query()->create([
'title' => 'Instanzen zeitweise nicht erreichbar',
'impact' => 'down', 'components' => ['instances'],
'started_at' => now()->subDays(3),
'resolved_at' => now()->subDays(3)->addHour(),
'published_at' => now()->subDays(3),
]);
$incident->updates()->create(['status' => 'resolved', 'body' => 'Seit 20:51 stabil.']);
$this->get('/status')
->assertOk()
->assertSee('Instanzen zeitweise nicht erreichbar')
->assertSee('Seit 20:51 stabil.')
->assertSee(__('status.incident.stage.resolved'));
});
it('does not publish a draft', function () {
// Drafts exist so an operator can write carefully during an outage instead
// of publishing a sentence they then correct in front of everybody.
Incident::query()->create([
'title' => 'Interner Entwurf',
'impact' => 'down', 'components' => ['portal'],
'started_at' => now()->subHour(),
'published_at' => null,
]);
$this->get('/status')->assertOk()->assertDontSee('Interner Entwurf');
});
it('lets an open incident make a component look worse, never better', function () {
// The direction matters. An incident that could improve a component's
// reported state is a switch for hiding an outage.
Instance::factory()->create(['status' => 'active']); // → instances is 'unknown'
Incident::query()->create([
'title' => 'Ausfall', 'impact' => 'down', 'components' => ['instances', 'portal'],
'started_at' => now()->subHour(), 'published_at' => now()->subHour(),
])->updates()->create(['status' => 'investigating', 'body' => 'Wir untersuchen das.']);
$components = collect($this->get('/status')->viewData('components'))->keyBy('key');
expect($components['instances']['state'])->toBe('down')
// The portal answered this very request; an incident naming it cannot
// turn a measurement into a claim, but it may still darken it.
->and($components['portal']['state'])->toBe('down');
// And the banner follows the worst component.
expect($this->get('/status')->viewData('overall'))->toBe('down');
});
it('shows an incident that is not over at the top, not filed under history', function () {
Incident::query()->create([
'title' => 'Läuft noch', 'impact' => 'degraded', 'components' => ['backups'],
'started_at' => now()->subHour(), 'published_at' => now()->subHour(),
])->updates()->create(['status' => 'investigating', 'body' => 'Wird untersucht.']);
$page = $this->get('/status');
expect($page->viewData('ongoing'))->toHaveCount(1)
->and($page->viewData('past'))->toBeEmpty();
$page->assertSee(__('status.incident.ongoing'));
});
it('measures whether the hosts answer at all', function () {
// hosts.last_seen_at drove the console's health dot and was written exactly
// once, at onboarding — so every host read "offline" half an hour later,
// for good. Nothing was measuring host reachability, which is why it could
// not appear here.
App\Models\Host::factory()->create(['status' => 'active', 'last_seen_at' => now()]);
App\Models\Host::factory()->create(['status' => 'active', 'last_seen_at' => now()->subHours(2)]);
$components = collect(app(App\Services\Status\ServiceHealth::class)->components())->keyBy('key');
expect($components['hosts']['state'])->toBe('degraded')
->and($components['hosts']['detail'])->toBe(['down' => 1, 'total' => 2]);
});
it('does not call a host that has stopped answering healthy', function () {
// healthState() has three answers and they are not two: "stale" is between
// five and thirty minutes without a word. Not yet an outage, and certainly
// not health.
App\Models\Host::factory()->create(['status' => 'active', 'last_seen_at' => now()->subMinutes(12)]);
$components = collect(app(App\Services\Status\ServiceHealth::class)->components())->keyBy('key');
expect($components['hosts']['state'])->toBe('degraded');
});
it('names no host on a page the whole internet can read', function () {
// Counts only. The estate is not public information.
App\Models\Host::factory()->create(['status' => 'active', 'name' => 'pve-wien-01', 'last_seen_at' => now()->subDay()]);
$this->get('/status')->assertOk()->assertDontSee('pve-wien-01');
});