diff --git a/app/Console/Commands/RemindEndingServices.php b/app/Console/Commands/RemindEndingServices.php index 46ccae3..4b1de98 100644 --- a/app/Console/Commands/RemindEndingServices.php +++ b/app/Console/Commands/RemindEndingServices.php @@ -63,8 +63,15 @@ class RemindEndingServices extends Command $customer = $instance->customer; if ($customer === null || blank($customer->email)) { - // Laut, aber ohne export_reminded_at zu setzen: eine Mail, die - // nie eine Adresse hatte, ist keine, die verschickt wurde. + // $customer === null ist über die Anwendung praktisch + // unerreichbar: instances.customer_id ist ein + // Pflicht-Fremdschlüssel mit Kaskade, und customers.email ist + // NOT NULL. Trotzdem abgefangen statt vorausgesetzt — eine + // Instanz ohne ansprechbaren Kunden soll den ganzen Lauf + // nicht mitreißen, welcher Zustand sie auch dorthin gebracht + // hätte. Laut, aber ohne export_reminded_at zu setzen: eine + // Mail, die nie eine Adresse hatte, ist keine, die verschickt + // wurde. Log::warning('Instanz nähert sich dem Laufzeitende, aber kein erreichbarer Kunde dahinter.', [ 'instance' => $instance->uuid, ]); @@ -85,8 +92,18 @@ class RemindEndingServices extends Command try { $mail = new ServiceEndingSoonMail($instance); Mail::mailer($mail->mailer)->to($customer->email)->queue($mail); + + // In DERSELBEN Absicherung wie der Versand, nicht danach: + // hinge ausgerechnet dieser Schreibzugriff (Datenbank kurz + // weg), bliebe die Marke leer, obwohl die Mail schon + // eingereiht ist — derselbe Kunde bekäme morgen eine zweite. + // Und ein ungefangener Fehler hier würde den ganzen Lauf + // abbrechen, statt nur diese eine Instanz zu überspringen — + // die restlichen Instanzen des Tages kämen erst morgen an + // die Reihe, ein Siebtel der sieben Tage Vorlauf verloren. + $instance->update(['export_reminded_at' => now()]); } catch (Throwable $e) { - Log::warning('Die Erinnerung vor dem Laufzeitende ließ sich nicht einreihen.', [ + Log::warning('Die Erinnerung vor dem Laufzeitende ließ sich nicht einreihen oder nicht vermerken.', [ 'instance' => $instance->uuid, 'exception' => $e->getMessage(), ]); @@ -94,9 +111,6 @@ class RemindEndingServices extends Command continue; } - // Hält fest, dass erinnert wurde — ohne diese Spalte schickte der - // tägliche Lauf dieselbe Mail an jedem der restlichen Tage erneut. - $instance->update(['export_reminded_at' => now()]); $reminded++; } diff --git a/tests/Feature/Cancellation/RemindEndingServicesTest.php b/tests/Feature/Cancellation/RemindEndingServicesTest.php index 9890835..2b13624 100644 --- a/tests/Feature/Cancellation/RemindEndingServicesTest.php +++ b/tests/Feature/Cancellation/RemindEndingServicesTest.php @@ -2,6 +2,7 @@ use App\Mail\ServiceEndingSoonMail; use App\Models\Instance; +use App\Support\ProvisioningSettings; use Illuminate\Support\Facades\Mail; it('erinnert sieben Tage vor dem Ende', function () { @@ -17,6 +18,21 @@ it('erinnert sieben Tage vor dem Ende', function () { expect($instance->fresh()->export_reminded_at)->not->toBeNull(); }); +it('erinnert auch, wenn das Ende genau in sieben Tagen liegt', function () { + // Die obere Einschlussgrenze, gegenüber dem unteren Rand unten: die + // Auswahl ist "höchstens sieben Tage", also gehört der siebte Tag noch + // dazu, nicht erst der sechste. + Mail::fake(); + Instance::factory()->create([ + 'status' => 'cancellation_scheduled', + 'service_ends_at' => now()->addDays(7), + ]); + + $this->artisan('clupilot:remind-ending-services')->assertSuccessful(); + + Mail::assertQueued(ServiceEndingSoonMail::class); +}); + it('erinnert nicht zweimal', function () { // Ein täglicher Zeitplan würde die Erinnerung sonst an jedem der // restlichen Tage erneut schicken. Eine Erinnerung im Sechserpack liest @@ -45,6 +61,23 @@ it('erinnert nicht, wenn das Ende noch weit weg ist', function () { Mail::assertNothingQueued(); }); +it('erinnert nicht mehr, wenn das Ende schon vorbei ist', function () { + // Kein akademischer Rand: EndInstanceService lässt einen DNS-Fehler + // bewusst durch, eine gekündigte Instanz mit abgelaufener Frist kann also + // real tagelang stehenbleiben, bis der stündliche Lauf durchkommt. Genau + // dann darf keine Erinnerung mehr ausgehen — die Tür ist zu, und eine + // Aufforderung zum Selbst-Herunterladen wäre eine Aufforderung ins Leere. + Mail::fake(); + Instance::factory()->create([ + 'status' => 'cancellation_scheduled', + 'service_ends_at' => now()->subHour(), + ]); + + $this->artisan('clupilot:remind-ending-services')->assertSuccessful(); + + Mail::assertNothingQueued(); +}); + it('erinnert eine Instanz, die gar nicht gekündigt wurde, nicht', function () { Mail::fake(); Instance::factory()->create(['status' => 'active', 'service_ends_at' => now()->addDays(6)]); @@ -80,7 +113,10 @@ it('trägt in der Mail das Datum und den Weg zum Selbst-Herunterladen', function $text = (new ServiceEndingSoonMail($instance))->render(); // Kein roher Schlüssel, und die zwei Dinge, um die es geht: ab wann ist - // zu, und wie komme ich vorher an meine Sachen. + // zu, und wie komme ich vorher an meine Sachen — die Adresse selbst muss + // also wirklich im Text stehen, nicht nur der Knopf, der theoretisch + // hinführen könnte. expect($text)->not->toContain('service_ending.') - ->and($text)->toContain($instance->service_ends_at->local()->isoFormat('LL')); + ->and($text)->toContain($instance->service_ends_at->local()->isoFormat('LL')) + ->and($text)->toContain('https://'.$instance->address(ProvisioningSettings::dnsZone())); });