CluPilotCloud/tests/Feature/Admin/MaintenanceTest.php

146 lines
6.5 KiB
PHP

<?php
use App\Livewire\Admin\Maintenance;
use App\Mail\MaintenanceAnnouncementMail;
use App\Models\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\MaintenanceNotification;
use App\Models\MaintenanceWindow;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
// operator() helper defined in RbacTest.
function affectedCustomerOn(Host $host): Customer
{
$customer = Customer::factory()->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', now()->addDay()->format('Y-m-d\TH:i'))
->set('endsAt', now()->addDay()->addHours(2)->format('Y-m-d\TH:i'))
->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', now()->addDay()->format('Y-m-d\TH:i'))
->set('endsAt', now()->addDay()->addHours(2)->format('Y-m-d\TH:i'))
->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', now()->addDay()->format('Y-m-d\TH:i'))
->set('endsAt', now()->addDay()->addHours(2)->format('Y-m-d\TH:i'))
->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();
});