Eine Warnung, bevor die eigene Cloud den Kunden aussperrt
Ab dem Laufzeitende zieht EndInstanceService die Adresse ein — der Kunde kommt
ab dem Moment nicht mehr an seine Nextcloud. Wer selbst etwas herunterladen
will, muss es VORHER tun, und das wusste bisher niemand.
Die Auswahl ist bewusst "hoechstens sieben Tage" und nicht "genau sieben Tage":
ein Gleichheitsvergleich verfehlt jede Instanz, die zwischen zwei Laeufen
durchrutscht, und der Preis dafuer waere, dass jemand ausgesperrt wird, ohne es
gewusst zu haben.
Zusaetzlich zum Zettel: lang/{de,en}/mail_pace.php bekommen einen Eintrag fuer
ServiceEndingSoonMail, weil MailPacePageTest fuer jede Klasse in MailLane::all()
einen Anzeigenamen verlangt — ohne ihn waere die Suite rot.
claude/nice-moser-521659
parent
ae8c8680bb
commit
e83b1d886a
|
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\ServiceEndingSoonMail;
|
||||
use App\Models\Instance;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Die Erinnerung, bevor EndInstanceService die Adresse einzieht.
|
||||
*
|
||||
* Am Laufzeitende zieht EndInstanceService die Route und den DNS-Eintrag
|
||||
* einer gekündigten Instanz ein — richtig so, das bleibt unangetastet. Die
|
||||
* Folge, die bisher niemand aussprach: ab genau diesem Moment kommt der Kunde
|
||||
* selbst nicht mehr an seine Nextcloud. Wer eigene Dateien herunterladen
|
||||
* will, muss es VORHER tun, und dieser Befehl ist der einzige Ort im Bestand,
|
||||
* der ihm das vorher sagt.
|
||||
*
|
||||
* Das Gegenstück zu App\Console\Commands\EndDueServices — der beendet, was
|
||||
* fällig ist, dieser hier kündigt an, was demnächst fällig wird. Dieselbe
|
||||
* Bauform: `--dry-run`, eine Zeile pro betroffener Instanz, eine
|
||||
* zusammenfassende Meldung am Ende.
|
||||
*
|
||||
* ## Die Auswahl: höchstens sieben Tage, nicht genau sieben
|
||||
*
|
||||
* Ein Gleichheitsvergleich auf einen Tag verfehlt jede Instanz, die zwischen
|
||||
* zwei täglichen Läufen durchrutscht — ein Lauf, der einen Tag ausfällt,
|
||||
* oder eine Instanz, die mit kurzer Frist gekündigt wurde und deren Ende von
|
||||
* Anfang an weniger als sieben Tage entfernt lag. Der Preis eines verfehlten
|
||||
* Vergleichs ist, dass jemand ausgesperrt wird, ohne es gewusst zu haben —
|
||||
* lieber spät erinnert als gar nicht. `export_reminded_at` sorgt dafür, dass
|
||||
* daraus trotzdem nur EINE Mail wird: eine Erinnerung im Sechserpack liest
|
||||
* sich als Mahnung.
|
||||
*/
|
||||
class RemindEndingServices extends Command
|
||||
{
|
||||
protected $signature = 'clupilot:remind-ending-services
|
||||
{--dry-run : listet auf, wer erinnert würde, und ändert nichts}';
|
||||
|
||||
protected $description = 'Erinnert Kunden einmalig ans Selbst-Herunterladen, bevor ihre gekündigte Cloud unerreichbar wird';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$reminded = 0;
|
||||
|
||||
$due = Instance::query()
|
||||
->with('customer')
|
||||
->where('status', 'cancellation_scheduled')
|
||||
->whereNotNull('service_ends_at')
|
||||
// In der Zukunft und höchstens sieben Tage entfernt — siehe
|
||||
// Kopfkommentar. Eine bereits abgelaufene Instanz gehört
|
||||
// EndDueServices, nicht mehr dieser Erinnerung.
|
||||
->where('service_ends_at', '>', now())
|
||||
->where('service_ends_at', '<=', now()->addDays(7))
|
||||
->whereNull('export_reminded_at')
|
||||
->cursor();
|
||||
|
||||
foreach ($due as $instance) {
|
||||
$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.
|
||||
Log::warning('Instanz nähert sich dem Laufzeitende, aber kein erreichbarer Kunde dahinter.', [
|
||||
'instance' => $instance->uuid,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(($dryRun ? '[dry-run] ' : '')
|
||||
."{$instance->subdomain}: Laufzeit endet am "
|
||||
.$instance->service_ends_at->local()->isoFormat('LL'));
|
||||
|
||||
if ($dryRun) {
|
||||
$reminded++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$mail = new ServiceEndingSoonMail($instance);
|
||||
Mail::mailer($mail->mailer)->to($customer->email)->queue($mail);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Die Erinnerung vor dem Laufzeitende ließ sich nicht einreihen.', [
|
||||
'instance' => $instance->uuid,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
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++;
|
||||
}
|
||||
|
||||
$this->info($dryRun
|
||||
? "Probelauf: {$reminded} Erinnerung(en) würden verschickt. Nichts wurde geändert."
|
||||
: "{$reminded} Erinnerung(en) verschickt.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\RidesALane;
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Models\Instance;
|
||||
use App\Services\Mail\MailCatalogue;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
use App\Support\ProvisioningSettings;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Die Erinnerung, bevor EndInstanceService die Adresse einzieht.
|
||||
*
|
||||
* Ab dem Laufzeitende zieht EndInstanceService Route und DNS-Eintrag ein —
|
||||
* und ab genau diesem Moment kommt der Kunde nicht mehr an seine eigene
|
||||
* Nextcloud. Wer selbst etwas herunterladen will, muss es VORHER tun, und das
|
||||
* wusste bisher niemand: nichts im Bestand sagte es ihm. Diese Mail sagt es,
|
||||
* mit dem Datum und dem Weg zur eigenen Cloud.
|
||||
*
|
||||
* Geht an BEIDE Antworten auf die Exportfrage aus ConfirmCancelPackage — wer
|
||||
* beim Kündigen „nein" gesagt hat, braucht die Warnung am dringendsten, weil
|
||||
* für ihn sonst gar nichts vorbereitet wird. Nur wer „ja" gesagt hat, bekommt
|
||||
* zusätzlich den Hinweis, dass ein Export vorbereitet wird (App\Console\
|
||||
* Commands\RemindEndingServices entscheidet über das Ob, nicht diese Klasse).
|
||||
*/
|
||||
class ServiceEndingSoonMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
|
||||
|
||||
public function __construct(public Instance $instance)
|
||||
{
|
||||
$this->mailer(MailCatalogue::mailer('service-ending-soon'));
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return $this->mailboxEnvelope(
|
||||
MailPurpose::PROVISIONING,
|
||||
__('service_ending.subject', ['date' => $this->instance->service_ends_at->local()->isoFormat('LL')]),
|
||||
'service-ending-soon',
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'mail.service-ending-soon', with: [
|
||||
'name' => (string) ($this->instance->customer?->name ?? ''),
|
||||
// ->local() (R19): das Datum, das dem Kunden genannt wird, muss
|
||||
// seine eigene Wanduhr zeigen, nicht die Speicherzone UTC.
|
||||
'date' => $this->instance->service_ends_at->local()->isoFormat('LL'),
|
||||
// https:// davor, wie überall sonst, wo Instance::address() zu
|
||||
// einer klickbaren Adresse wird (z. B. App\Livewire\
|
||||
// Dashboard::domain()) — die eine Stelle, die eigene Domain vor
|
||||
// Subdomain entscheidet, statt das hier zu wiederholen.
|
||||
'url' => 'https://'.$this->instance->address(ProvisioningSettings::dnsZone()),
|
||||
'exportWish' => $this->instance->export_wish,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,11 @@ final class MailCatalogue
|
|||
'cloud-ready' => ['label' => 'Cloud ist bereit (Zugangsdaten)', 'purpose' => MailPurpose::PROVISIONING],
|
||||
'maintenance-announcement' => ['label' => 'Wartungsfenster angekündigt', 'purpose' => MailPurpose::MAINTENANCE],
|
||||
'maintenance-cancelled' => ['label' => 'Wartungsfenster abgesagt', 'purpose' => MailPurpose::MAINTENANCE],
|
||||
// Die Erinnerung vor dem Laufzeitende (App\Mail\ServiceEndingSoonMail).
|
||||
// Kein Rundschreiben und keine Werbung, sondern eine Frist, auf die
|
||||
// der Empfänger wartet — PROVISIONING wie cloud-ready, weil es um
|
||||
// die Maschine selbst geht, nicht um Geld.
|
||||
'service-ending-soon' => ['label' => 'Cloud endet bald (Erinnerung)', 'purpose' => MailPurpose::PROVISIONING],
|
||||
'invoice' => ['label' => 'Rechnung', 'purpose' => MailPurpose::BILLING],
|
||||
'new-device' => ['label' => 'Anmeldung von einem neuen Gerät', 'purpose' => MailPurpose::SYSTEM],
|
||||
'dormant-warning' => ['label' => 'Konto ohne Paket wird gelöscht', 'purpose' => MailPurpose::SYSTEM],
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use App\Mail\OperatorMessageMail;
|
|||
use App\Mail\OrderConfirmationMail;
|
||||
use App\Mail\ResetPasswordMail;
|
||||
use App\Mail\SecurityBlockMail;
|
||||
use App\Mail\ServiceEndingSoonMail;
|
||||
use App\Mail\VerifyEmailMail;
|
||||
use App\Support\Settings;
|
||||
use RuntimeException;
|
||||
|
|
@ -70,6 +71,9 @@ final class MailLane
|
|||
MaintenanceCancelledMail::class => self::URGENT,
|
||||
CloudSuspendedMail::class => self::URGENT,
|
||||
CloudResumedMail::class => self::URGENT,
|
||||
// Kein Rundschreiben und keine Werbung, sondern eine Frist, auf die
|
||||
// der Empfänger wartet — wie die Wartungsankündigung daneben.
|
||||
ServiceEndingSoonMail::class => self::URGENT,
|
||||
InvoiceMail::class => self::CALM,
|
||||
DunningNoticeMail::class => self::CALM,
|
||||
DormantAccountWarningMail::class => self::CALM,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ use App\Mail\OperatorMessageMail;
|
|||
use App\Mail\OrderConfirmationMail;
|
||||
use App\Mail\ResetPasswordMail;
|
||||
use App\Mail\SecurityBlockMail;
|
||||
use App\Mail\ServiceEndingSoonMail;
|
||||
use App\Mail\VerifyEmailMail;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
|
|
@ -107,6 +108,7 @@ class MailPreviews
|
|||
'cloud-ready' => $this->cloudReady($customer),
|
||||
'maintenance-announcement' => new MaintenanceAnnouncementMail($this->window(), $customer),
|
||||
'maintenance-cancelled' => new MaintenanceCancelledMail($this->window(), $customer),
|
||||
'service-ending-soon' => new ServiceEndingSoonMail($this->endingInstance($customer)),
|
||||
'invoice' => new InvoiceMail($this->invoice($customer), $customer->name),
|
||||
'new-device' => new NewDeviceSignInMail($customer->name, $this->device($user), 'web'),
|
||||
'dormant-warning' => new DormantAccountWarningMail($user, PruneDormantAccounts::WARN_DAYS_BEFORE),
|
||||
|
|
@ -243,6 +245,21 @@ class MailPreviews
|
|||
])->forceFill(['id' => 0])->setRelation('instance', $instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine gekündigte Instanz sechs Tage vor dem Laufzeitende, mit
|
||||
* gewünschtem Export — die Kombination, die den zusätzlichen Absatz
|
||||
* zeigt. make(), nie create(), wie alles hier.
|
||||
*/
|
||||
private function endingInstance(Customer $customer): Instance
|
||||
{
|
||||
return Instance::make([
|
||||
'subdomain' => 'nc-beispiel',
|
||||
'status' => 'cancellation_scheduled',
|
||||
'service_ends_at' => Carbon::now()->addDays(6),
|
||||
'export_wish' => true,
|
||||
])->forceFill(['id' => 0])->setRelation('customer', $customer);
|
||||
}
|
||||
|
||||
/**
|
||||
* The credentials mail is a notification rather than a Mailable.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ return [
|
|||
'MaintenanceCancelledMail' => 'Wartungsfenster abgesagt',
|
||||
'CloudSuspendedMail' => 'Cloud abgeschaltet',
|
||||
'CloudResumedMail' => 'Cloud läuft wieder',
|
||||
'ServiceEndingSoonMail' => 'Cloud endet bald (Erinnerung)',
|
||||
'InvoiceMail' => 'Rechnung',
|
||||
'DunningNoticeMail' => 'Zahlungserinnerungen und Mahnungen',
|
||||
'DormantAccountWarningMail' => 'Konto ohne Paket wird gelöscht',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
// Die Erinnerung, bevor EndInstanceService die Adresse einzieht — siehe
|
||||
// App\Mail\ServiceEndingSoonMail. Geht an beide Antworten auf die
|
||||
// Exportfrage; wer "nein" gesagt hat, braucht die Warnung am dringendsten.
|
||||
return [
|
||||
'subject' => 'Ihre Cloud endet am :date',
|
||||
'heading' => 'Bald ist Schluss',
|
||||
'preheader' => 'Sichern Sie sich, was Sie brauchen — ab :date kommen Sie nicht mehr hinein.',
|
||||
'greeting' => 'Guten Tag :name,',
|
||||
'intro' => 'Ihr Vertrag endet am :date. Ab diesem Zeitpunkt ziehen wir die Adresse Ihrer Cloud ein — Sie kommen dann nicht mehr an Ihre eigenen Dateien.',
|
||||
'how_to_download' => 'Melden Sie sich bis dahin selbst in Ihrer Cloud an und laden Sie herunter, was Sie behalten möchten.',
|
||||
'action' => 'Zur eigenen Cloud',
|
||||
'export_prepared' => 'Da Sie beim Kündigen einen Export gewünscht haben, bereiten wir zusätzlich einen für Sie vor — unabhängig davon lohnt es sich, vorher selbst zu sichern.',
|
||||
];
|
||||
|
|
@ -52,6 +52,7 @@ return [
|
|||
'MaintenanceCancelledMail' => 'Maintenance window cancelled',
|
||||
'CloudSuspendedMail' => 'Cloud suspended',
|
||||
'CloudResumedMail' => 'Cloud running again',
|
||||
'ServiceEndingSoonMail' => 'Cloud ending soon (reminder)',
|
||||
'InvoiceMail' => 'Invoice',
|
||||
'DunningNoticeMail' => 'Payment reminders and dunning notices',
|
||||
'DormantAccountWarningMail' => 'Account without a plan will be deleted',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'subject' => 'Your cloud ends on :date',
|
||||
'heading' => 'Time is running out',
|
||||
'preheader' => 'Save what you need — from :date you can no longer get in.',
|
||||
'greeting' => 'Hello :name,',
|
||||
'intro' => 'Your contract ends on :date. From that point we withdraw the address of your cloud — you will no longer be able to reach your own files.',
|
||||
'how_to_download' => 'Sign in to your cloud yourself before then and download whatever you want to keep.',
|
||||
'action' => 'Go to my cloud',
|
||||
'export_prepared' => 'Since you asked for an export when you cancelled, we are additionally preparing one for you — either way, it is worth saving your own copy first.',
|
||||
];
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<x-mail.layout
|
||||
:heading="__('service_ending.heading')"
|
||||
:preheader="__('service_ending.preheader', ['date' => $date])"
|
||||
:greeting="$name !== '' ? __('service_ending.greeting', ['name' => $name]) : null"
|
||||
>
|
||||
|
||||
<tr><td style="padding:0 24px 20px 24px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('service_ending.intro', ['date' => $date]) }}</p>
|
||||
</td></tr>
|
||||
|
||||
{{-- Das eine Ziel dieser Mail: bis wann, und wie komme ich vorher an meine
|
||||
eigenen Dateien. Ein Knopf statt einer Adresse im Fliesstext, weil das
|
||||
hier keine Nebeninformation ist, sondern der Grund, warum die Mail
|
||||
verschickt wird. --}}
|
||||
<tr><td style="padding:0 24px {{ $exportWish === true ? '0' : '32px' }} 24px;">
|
||||
<p style="margin:0 0 16px 0;font-size:15px;line-height:24px;color:#43434e;">{{ __('service_ending.how_to_download') }}</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
|
||||
<a href="{{ $url }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ __('service_ending.action') }}</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
{{-- Nur, wer beim Kündigen "ja" gesagt hat. Ein "nein" bedeutet ausdrücklich
|
||||
"bereitet nichts vor" (ConfirmCancelPackage) — ihm hier trotzdem einen
|
||||
Export anzukündigen wäre ein Versprechen, das niemand eingelöst hätte. --}}
|
||||
@if ($exportWish === true)
|
||||
<tr><td style="padding:20px 24px 32px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
|
||||
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('service_ending.export_prepared') }}</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@endif
|
||||
|
||||
</x-mail.layout>
|
||||
|
|
@ -251,6 +251,21 @@ Schedule::command('clupilot:end-due-services')
|
|||
->hourly()
|
||||
->withoutOverlapping();
|
||||
|
||||
// Die Erinnerung, bevor der Lauf oben die Adresse einzieht.
|
||||
//
|
||||
// Ab dem Laufzeitende kommt der Kunde selbst nicht mehr an seine Nextcloud —
|
||||
// wer eigene Dateien will, muss es VORHER tun, und bislang erfuhr er das
|
||||
// nirgends. RemindEndingServices sagt es ihm einmalig, bis zu sieben Tage
|
||||
// vorher.
|
||||
//
|
||||
// Täglich statt stündlich: die Erinnerung hat einen Tag Spielraum, und eine
|
||||
// Mail, die um drei Uhr nachts kommt, liest niemand anders als eine um neun —
|
||||
// anders als beim Mahnlauf oben verspricht hier niemand eine Geschäftsstunde,
|
||||
// nur einen Termin mit einer Woche Vorlauf.
|
||||
Schedule::command('clupilot:remind-ending-services')
|
||||
->dailyAt('03:50')
|
||||
->withoutOverlapping();
|
||||
|
||||
// Zertifikate: welche Namen bedient diese Installation, und wie lange gelten
|
||||
// sie noch.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
<?php // tests/Feature/Cancellation/RemindEndingServicesTest.php
|
||||
|
||||
use App\Mail\ServiceEndingSoonMail;
|
||||
use App\Models\Instance;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
it('erinnert sieben Tage vor dem Ende', function () {
|
||||
Mail::fake();
|
||||
$instance = Instance::factory()->create([
|
||||
'status' => 'cancellation_scheduled',
|
||||
'service_ends_at' => now()->addDays(6),
|
||||
]);
|
||||
|
||||
$this->artisan('clupilot:remind-ending-services')->assertSuccessful();
|
||||
|
||||
Mail::assertQueued(ServiceEndingSoonMail::class);
|
||||
expect($instance->fresh()->export_reminded_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
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
|
||||
// sich als Mahnung.
|
||||
Mail::fake();
|
||||
Instance::factory()->create([
|
||||
'status' => 'cancellation_scheduled',
|
||||
'service_ends_at' => now()->addDays(6),
|
||||
'export_reminded_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$this->artisan('clupilot:remind-ending-services')->assertSuccessful();
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
it('erinnert nicht, wenn das Ende noch weit weg ist', function () {
|
||||
Mail::fake();
|
||||
Instance::factory()->create([
|
||||
'status' => 'cancellation_scheduled',
|
||||
'service_ends_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
$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)]);
|
||||
|
||||
$this->artisan('clupilot:remind-ending-services')->assertSuccessful();
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
it('erinnert auch dann, wenn das Ende schon in wenigen Stunden ist', function () {
|
||||
// Der wichtigste Fall und der, den ein "genau sieben Tage"-Vergleich
|
||||
// verfehlt: eine Instanz, die zwischen zwei Läufen durchgerutscht ist,
|
||||
// oder eine, die mit kurzer Frist gekündigt wurde. Lieber spät erinnern
|
||||
// als gar nicht — ab dem Laufzeitende kommt der Kunde nicht mehr hinein.
|
||||
Mail::fake();
|
||||
Instance::factory()->create([
|
||||
'status' => 'cancellation_scheduled',
|
||||
'service_ends_at' => now()->addHours(3),
|
||||
]);
|
||||
|
||||
$this->artisan('clupilot:remind-ending-services')->assertSuccessful();
|
||||
|
||||
Mail::assertQueued(ServiceEndingSoonMail::class);
|
||||
});
|
||||
|
||||
it('trägt in der Mail das Datum und den Weg zum Selbst-Herunterladen', function () {
|
||||
$instance = Instance::factory()->create([
|
||||
'status' => 'cancellation_scheduled',
|
||||
'service_ends_at' => now()->addDays(6),
|
||||
'export_wish' => false,
|
||||
]);
|
||||
|
||||
$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.
|
||||
expect($text)->not->toContain('service_ending.')
|
||||
->and($text)->toContain($instance->service_ends_at->local()->isoFormat('LL'));
|
||||
});
|
||||
Loading…
Reference in New Issue