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'); });