Fix-Runde: Markierung im selben Versuch wie der Versand, zwei Randpruefungen
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details

$instance->update(['export_reminded_at' => now()]) lag ausserhalb des
try/catch, das nur den Mailversand absicherte. Ein Fehlschlag dieses einen
Schreibzugriffs haette die Marke leer gelassen (morgen eine zweite Mail fuer
dieselbe Instanz) UND den ganzen Lauf abgebrochen, statt nur diese eine
Instanz zu ueberspringen. Jetzt beides in derselben Absicherung.

Dazu zwei Pruefungen, die der Rand "Ende schon vorbei" bislang nicht hatte:
keine Erinnerung mehr, wenn die Frist schon abgelaufen ist (kein akademischer
Fall - EndInstanceService laesst einen DNS-Fehler bewusst durch, eine
Instanz kann also real tagelang mit abgelaufener Frist stehenbleiben), und
die obere Einschlussgrenze bei genau sieben Tagen, damit beide Raender der
"hoechstens sieben Tage"-Regel durch je eine eigene Pruefung belegt sind.

Ausserdem: die Selbst-Herunterladen-Adresse selbst wird jetzt im Rendertest
geprueft, nicht nur Datum und Sprachschluessel.
claude/nice-moser-521659
nexxo 2026-08-04 09:14:33 +02:00
parent 6f01097558
commit 5eef03d267
2 changed files with 58 additions and 8 deletions

View File

@ -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++;
}

View File

@ -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()));
});