208 lines
9.0 KiB
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');
|
|
});
|