test(IncidentConsole::class) ->set('title', 'Instanzen nicht erreichbar') ->set('components', ['instances']) ->set('impact', 'down') ->set('startedAt', LocalTime::toField(now()->subMinutes(20))) ->set('firstUpdate', 'Mehrere Instanzen antworten nicht.') ->call('create') ->assertHasNoErrors(); $incident = Incident::query()->firstOrFail(); // A headline with nothing under it is what a reader would see if the // second insert were allowed to fail on its own. expect($incident->updates)->toHaveCount(1) ->and($incident->updates->first()->status)->toBe('investigating') ->and($incident->isPublished())->toBeTrue() ->and($incident->components)->toBe(['instances']); }); it('refuses an incident that names no service', function () { Livewire::actingAs(operator('Owner'), 'operator') ->test(IncidentConsole::class) ->set('title', 'Irgendwas') ->set('components', []) ->set('firstUpdate', 'Text') ->call('create') ->assertHasErrors('components'); expect(Incident::query()->count())->toBe(0); }); it('closes the incident with the same action that says it is resolved', function () { // Two separate actions is how a status page ends up with a "behoben" note // under an incident that still reports as ongoing. $incident = Incident::query()->create([ 'title' => 'x', 'impact' => 'down', 'components' => ['portal'], 'started_at' => now()->subHour(), 'published_at' => now()->subHour(), ]); Livewire::actingAs(operator('Owner'), 'operator') ->test(IncidentConsole::class) ->call('startUpdate', $incident->uuid) ->set('updateStage', 'resolved') ->set('updateBody', 'Seit 20:51 stabil.') ->call('postUpdate') ->assertHasNoErrors(); expect($incident->fresh()->isResolved())->toBeTrue() ->and($incident->fresh()->stage())->toBe('resolved'); }); it('takes a correction as a further entry, never as an edit', function () { // An update is a statement made at a time. There is deliberately no way // here to change one after the fact. $incident = Incident::query()->create([ 'title' => 'x', 'impact' => 'degraded', 'components' => ['backups'], 'started_at' => now()->subHour(), 'published_at' => now()->subHour(), ]); $incident->updates()->create(['status' => 'investigating', 'body' => 'Erste Einschätzung.']); Livewire::actingAs(operator('Owner'), 'operator') ->test(IncidentConsole::class) ->call('startUpdate', $incident->uuid) ->set('updateStage', 'identified') ->set('updateBody', 'Korrektur: es betrifft nur einen Host.') ->call('postUpdate'); $bodies = $incident->fresh()->updates->pluck('body'); expect($bodies)->toHaveCount(2) ->and($bodies)->toContain('Erste Einschätzung.'); expect(class_exists(App\Livewire\Admin\Incidents::class))->toBeTrue(); expect(method_exists(App\Livewire\Admin\Incidents::class, 'editUpdate'))->toBeFalse(); }); it('needs the capability, not merely an operator account', function () { // Asserted on the PAGE rather than on the action: render() authorizes too, // so an operator without the capability never gets far enough to press a // button — and a Livewire::test() against a component that refuses to // render fails with a snapshot error that says nothing about permissions. $this->actingAs(operator('Support'), 'operator') ->get(route('admin.incidents')) ->assertForbidden(); }); it('records the operator who wrote each line', function () { // "Nachvollziehbar" includes who said it. Not shown publicly — this is for // the operator looking back at their own record months later. $owner = operator('Owner'); Livewire::actingAs($owner, 'operator') ->test(IncidentConsole::class) ->set('title', 'x') ->set('components', ['portal']) ->set('startedAt', LocalTime::toField(now())) ->set('firstUpdate', 'Text') ->call('create'); $incident = Incident::query()->firstOrFail(); expect($incident->created_by)->toBe($owner->email) ->and($incident->updates->first()->created_by)->toBe($owner->email); });