create(); Instance::factory()->create(['customer_id' => $customer->id, 'host_id' => $host->id, 'status' => 'active']); return $customer; } it('publishes a window and emails affected customers once (idempotent)', function () { Mail::fake(); $host = Host::factory()->active()->create(); $customer = affectedCustomerOn($host); Livewire::actingAs(operator('Owner'))->test(Maintenance::class) ->set('title', 'Netz-Wartung') ->set('startsAt', LocalTime::toField(now()->addDay())) ->set('endsAt', LocalTime::toField(now()->addDay()->addHours(2))) ->set('hostIds', [$host->id]) ->call('publish')->assertHasNoErrors(); $window = MaintenanceWindow::query()->where('title', 'Netz-Wartung')->first(); expect($window)->not->toBeNull()->and($window->state)->toBe('scheduled') ->and($window->hosts()->count())->toBe(1); Mail::assertQueued(MaintenanceAnnouncementMail::class, 1); expect(MaintenanceNotification::query()->where('customer_id', $customer->id)->count())->toBe(1); // The ledger's unique (window, customer, event) makes re-announcing a no-op. expect(fn () => MaintenanceNotification::query()->create([ 'maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => 'announcement', 'email' => $customer->email, ]))->toThrow(Illuminate\Database\UniqueConstraintViolationException::class); // Resending while the first mail is still pending must NOT duplicate. Livewire::actingAs(operator('Owner'))->test(Maintenance::class)->call('resend', $window->uuid); Mail::assertQueued(MaintenanceAnnouncementMail::class, 1); }); it('emails a cancellation to customers who were announced', function () { Mail::fake(); $host = Host::factory()->active()->create(); $customer = affectedCustomerOn($host); $window = MaintenanceWindow::factory()->scheduled()->create(); $window->hosts()->attach($host->id); // Simulate the customer having actually received the announcement. \App\Models\MaintenanceNotification::create([ 'maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => 'announcement', 'email' => $customer->email, 'sent_at' => now(), ]); Livewire::actingAs(operator('Owner'))->test(Maintenance::class)->call('cancel', $window->uuid); expect($window->fresh()->state)->toBe('cancelled'); Mail::assertQueued(\App\Mail\MaintenanceCancelledMail::class, 1); }); it('scopes the per-instance badge to that instance host', function () { $hostA = Host::factory()->active()->create(); $hostB = Host::factory()->active()->create(); $customer = Customer::factory()->create(); $instA = Instance::factory()->create(['customer_id' => $customer->id, 'host_id' => $hostA->id, 'status' => 'active']); $instB = Instance::factory()->create(['customer_id' => $customer->id, 'host_id' => $hostB->id, 'status' => 'active']); // A window on host A only. $window = MaintenanceWindow::factory()->scheduled()->create(); $window->hosts()->attach($hostA->id); expect(MaintenanceWindow::forInstance($instA))->toHaveCount(1) ->and(MaintenanceWindow::forInstance($instB))->toHaveCount(0) // not another instance's window ->and(MaintenanceWindow::bannerFor($customer))->toHaveCount(1); // customer-level banner still shows }); it('requires a host to publish', function () { Livewire::actingAs(operator('Owner'))->test(Maintenance::class) ->set('title', 'X') ->set('startsAt', LocalTime::toField(now()->addDay())) ->set('endsAt', LocalTime::toField(now()->addDay()->addHours(2))) ->set('hostIds', []) ->call('publish')->assertHasErrors(['hostIds']); }); it('forbids non-privileged roles from managing maintenance', function () { Livewire::actingAs(operator('Support'))->test(Maintenance::class) ->set('title', 'X')->set('startsAt', LocalTime::toField(now()->addDay())) ->set('endsAt', LocalTime::toField(now()->addDay()->addHours(2))) ->call('saveDraft')->assertForbidden(); }); it('shows the banner to an affected customer within the horizon', function () { $host = Host::factory()->active()->create(); $customer = affectedCustomerOn($host); $window = MaintenanceWindow::factory()->scheduled()->create(['starts_at' => now()->addDay(), 'ends_at' => now()->addDay()->addHour()]); $window->hosts()->attach($host->id); $banner = MaintenanceWindow::bannerFor($customer); expect($banner)->toHaveCount(1)->and($banner->first()->id)->toBe($window->id); // A customer on another host sees nothing. $other = affectedCustomerOn(Host::factory()->active()->create()); expect(MaintenanceWindow::bannerFor($other))->toHaveCount(0); }); it('fills the end time from a duration chip', function () { $owner = operator('Owner'); // With a start set, the chip just adds the minutes. $component = Livewire::actingAs($owner)->test(App\Livewire\Admin\Maintenance::class) ->set('startsAt', '2026-08-01T22:00') ->call('setDuration', 120); expect($component->get('endsAt'))->toBe('2026-08-02T00:00') ->and($component->instance()->durationMinutes())->toBe(120); // Without one, it starts at the next half hour rather than doing nothing. $fresh = Livewire::actingAs($owner)->test(App\Livewire\Admin\Maintenance::class) ->call('setDuration', 60); expect($fresh->get('startsAt'))->not->toBe('') ->and($fresh->instance()->durationMinutes())->toBe(60); }); it('reports no duration while the times are unusable', function () { $component = Livewire::actingAs(operator('Owner'))->test(App\Livewire\Admin\Maintenance::class); expect($component->instance()->durationMinutes())->toBeNull(); // A half-typed value must not blow up the live-updating form. $component->set('startsAt', '2026-08-')->set('endsAt', ''); expect($component->instance()->durationMinutes())->toBeNull(); // An end before the start is not a duration either. $component->set('startsAt', '2026-08-01T22:00')->set('endsAt', '2026-08-01T21:00'); expect($component->instance()->durationMinutes())->toBeNull(); });