diff --git a/app/Livewire/Admin/Instances.php b/app/Livewire/Admin/Instances.php
index 18bc14d..38d324a 100644
--- a/app/Livewire/Admin/Instances.php
+++ b/app/Livewire/Admin/Instances.php
@@ -72,6 +72,39 @@ class Instances extends Component
return view('livewire.admin.instances', [
'instances' => $instances,
+ // Die bestellten Datenexporte, ueber der Liste.
+ //
+ // `export_wish` stand bis hierher in einer Spalte, die kein Mensch
+ // zu Gesicht bekam: ein „Ja" aus dem Kuendigungsdialog hatte in
+ // der ganzen Konsole keinen einzigen Leser, und selbst ein Ausweg
+ // von Hand haette eine Datenbankabfrage gebraucht. Eine Frage
+ // ohne Empfaenger ist keine Frage.
+ //
+ // Bewusst ein eigener Abschnitt und keine Plakette in der Zeile:
+ // die Tabelle darunter ist nach id absteigend seitenweise
+ // geblaettert, ein vor Monaten bestellter Export saesse also auf
+ // Seite acht. Und bewusst hier statt auf der Uebersicht — die
+ // Liste ist die Seite, auf die ein Betreiber nach einer
+ // Kuendigung ohnehin schaut, und ein Hinweis auf der Startseite,
+ // den nichts je wieder abraeumen kann, waere Moebel.
+ //
+ // Nach Laufzeitende sortiert, nicht nach id: der naechste Termin
+ // steht oben. `ended` bleibt drin — dann ist der Export faellig,
+ // nicht erledigt.
+ 'exports' => Instance::query()
+ ->with('customer')
+ ->where('export_wish', true)
+ ->whereIn('status', ['cancellation_scheduled', 'ended'])
+ ->orderBy('service_ends_at')
+ ->get()
+ ->map(fn (Instance $i) => [
+ 'customer' => $i->customer?->name ?? '—',
+ 'address' => $i->domainIsVerified() ? $i->custom_domain : $i->subdomain,
+ // R19: die Wanduhr des Betreibers, nicht die Speicherzone.
+ 'ends_at' => $i->service_ends_at?->local()->isoFormat('LL') ?? '—',
+ 'over' => $i->service_ends_at !== null && $i->service_ends_at->isPast(),
+ ])
+ ->all(),
// Whether to draw the action column at all. An operator who may not
// restart anything should not be reading a column of buttons that
// answer them 403.
diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php
index ec8148c..959a046 100644
--- a/app/Livewire/Dashboard.php
+++ b/app/Livewire/Dashboard.php
@@ -82,10 +82,44 @@ class Dashboard extends Component
// Die Frist, die den Kunden betrifft — wie lange SEIN Zugang noch
// steht, nicht wann irgendetwas geloescht wird. Siehe ending().
'ending' => $this->ending($instance),
+ // Wann die Laufzeit endete — nur, wenn gar keine Instanz mehr in
+ // Betrieb ist. Siehe endedAt().
+ 'endedAt' => $this->endedAt($customer, $instance),
'asOf' => Carbon::now(),
]);
}
+ /**
+ * Wann die Laufzeit dieses Kunden endete — oder null.
+ *
+ * Die Abfrage oben holt nur, was in Betrieb ist; eine `ended`-Instanz
+ * faellt heraus, und der Kunde landete dadurch in demselben Zweig wie
+ * jemand, der noch nie etwas bestellt hat. Er las „Ihre Cloud wird
+ * eingerichtet." und bekam einen Knopf „Paket buchen" — am Tag, an dem
+ * ihm EndInstanceService die Adresse eingezogen hat.
+ *
+ * Nur gefragt, wenn nichts mehr laeuft: wer neben der abgelaufenen
+ * Instanz schon wieder eine aktive hat, ist kein beendeter Kunde, und
+ * seine Seite soll von der alten nichts erzaehlen.
+ *
+ * `service_ends_at` ist dabei nie null — `ended` wird ausschliesslich
+ * ueber EndInstanceService::hasEnded() erreicht, und die verlangt das
+ * Datum. Trotzdem hier verlangt, statt darauf zu vertrauen: ein Satz mit
+ * „endete am —" waere schlimmer als der Zweig darunter.
+ */
+ private function endedAt(?Customer $customer, ?Instance $instance): ?Carbon
+ {
+ if ($customer === null || $instance !== null) {
+ return null;
+ }
+
+ return $customer->instances()
+ ->where('status', 'ended')
+ ->whereNotNull('service_ends_at')
+ ->latest('id')
+ ->first()?->service_ends_at;
+ }
+
/**
* Aendert die Antwort zum Export, solange sie noch etwas bedeutet: bis
* zum Laufzeitende. Wie acknowledgeCredentials() loest diese Methode
diff --git a/app/Mail/ServiceEndingSoonMail.php b/app/Mail/ServiceEndingSoonMail.php
index 4a37210..aec72fd 100644
--- a/app/Mail/ServiceEndingSoonMail.php
+++ b/app/Mail/ServiceEndingSoonMail.php
@@ -27,7 +27,8 @@ use Illuminate\Queue\SerializesModels;
* 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\
+ * zusätzlich den Hinweis, dass sein Wunsch vermerkt ist und sich jemand meldet
+ * — nicht mehr, denn der Export selbst ist nicht gebaut (App\Console\
* Commands\RemindEndingServices entscheidet über das Ob, nicht diese Klasse).
*/
class ServiceEndingSoonMail extends Mailable implements ShouldQueue
diff --git a/lang/de/admin.php b/lang/de/admin.php
index 52adfa2..72f9ab0 100644
--- a/lang/de/admin.php
+++ b/lang/de/admin.php
@@ -153,6 +153,18 @@ return [
'pending_change' => 'Wechsel auf :plan am :date',
'by_plan' => 'Nach Paket',
'instances_sub' => 'Alle bereitgestellten Cloud-Instanzen.',
+
+ // Der einzige Ort, an dem die Antwort aus dem Kündigungsdialog
+ // (instances.export_wish) einen Leser hat — siehe
+ // App\Livewire\Admin\Instances::render(). Der Export selbst ist nicht
+ // gebaut; der Abschnitt sagt genau das, statt eine Warteschlange
+ // vorzutäuschen, die es nicht gibt.
+ 'export_wish' => [
+ 'title' => 'Datenexport bestellt',
+ 'sub' => 'Beim Kündigen ausdrücklich gewünscht. Der Export läuft noch von Hand — hier steht, für wen und bis wann.',
+ 'col_ends' => 'Laufzeitende',
+ 'over' => 'fällig',
+ ],
'instances_label' => 'Instanzen',
'hosts_sub' => 'Proxmox-Hosts und Kapazität.',
'storage_used' => 'Speicher belegt',
diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php
index 9446cb7..7b8cda6 100644
--- a/lang/de/dashboard.php
+++ b/lang/de/dashboard.php
@@ -30,6 +30,7 @@ return [
'sheet' => 'Betriebsblatt · Stand :when',
'title_running' => 'Ihre Cloud läuft.',
'title_pending' => 'Ihre Cloud wird eingerichtet.',
+ 'title_ended' => 'Ihre Cloud ist beendet.',
'instance_status' => [
'active' => 'Alle Dienste aktiv',
@@ -50,6 +51,22 @@ return [
'export_hint' => 'Sie können sich das bis zum Laufzeitende jederzeit anders überlegen.',
'export_yes' => 'Export gewünscht',
'export_no' => 'Kein Export gewünscht',
+ // Der dritte Zustand: `null` heißt, dass diese Frage nie gestellt
+ // wurde (gekündigt vor dieser Ausrollung). Der Streifen fragt dann,
+ // statt eine Antwort zu behaupten, die niemand gegeben hat — und sagt
+ // dazu, was passiert, wenn die Frage offen bleibt.
+ 'export_open' => 'Ob Sie einen Datenexport möchten, haben Sie uns noch nicht gesagt. Ohne Antwort bereiten wir keinen vor.',
+ ],
+
+ // Die Laufzeit ist vorbei, die Adresse eingezogen (App\Actions\
+ // EndInstanceService). Bis dahin fiel dieser Kunde in denselben Zweig wie
+ // jemand, der noch nie etwas bestellt hat — und las „Ihre Cloud wird
+ // eingerichtet." samt „Paket buchen". Das ist der eine Satz, der dort am
+ // wenigsten stehen darf.
+ 'ended' => [
+ 'label' => 'Ihre Laufzeit ist beendet',
+ 'body' => 'Ihr Paket endete am :date. Seitdem ist der Zugang zu Ihrer Nextcloud geschlossen.',
+ 'order_again' => 'Neues Paket buchen',
],
'no_instance_label' => 'Noch keine Instanz',
diff --git a/lang/de/service_ending.php b/lang/de/service_ending.php
index b02d9be..683c42a 100644
--- a/lang/de/service_ending.php
+++ b/lang/de/service_ending.php
@@ -11,5 +11,11 @@ return [
'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.',
+ // Sagt nur, was wirklich stimmt: der Wunsch steht vermerkt, und es meldet
+ // sich jemand. Der Satz behauptete vorher im Präsens, wir bereiteten
+ // bereits einen Export vor — den gibt es nicht, er hängt an einem
+ // Speicherserver, den es noch nicht gibt. Ein laufender Vorgang, den
+ // niemand ausführt, ist genau die Sorte Zusage, die dieser Kunde erst am
+ // letzten Tag als leer erkennt.
+ 'export_prepared' => 'Ihren Wunsch nach einem Datenexport haben wir vermerkt — wir melden uns dazu bei Ihnen. Laden Sie sich unabhängig davon bitte selbst herunter, was Sie behalten möchten.',
];
diff --git a/lang/de/settings.php b/lang/de/settings.php
index d8572fa..64bd633 100644
--- a/lang/de/settings.php
+++ b/lang/de/settings.php
@@ -58,7 +58,16 @@ return [
'no_package' => 'Kein aktives Paket.',
'cancel_cta' => 'Paket kündigen',
'cancel_scheduled_title' => 'Kündigung vorgemerkt',
- 'cancel_scheduled_body' => 'Ihr Paket endet am :date. Danach erhalten Sie Ihren Datenexport.',
+ // Drei Fassungen für die drei Zustände von instances.export_wish. Ein
+ // einziger Satz stand hier vorher und versprach JEDEM den Export — auch
+ // dem, der im Kündigungsdialog gerade bewusst „Nein" gewählt hatte und
+ // unmittelbar danach auf genau diese Seite umgeleitet wird
+ // (ConfirmCancelPackage::cancelPackage()).
+ 'cancel_scheduled_body_yes' => 'Ihr Paket endet am :date. Ihr Wunsch nach einem Datenexport ist vermerkt — wir melden uns dazu bei Ihnen.',
+ 'cancel_scheduled_body_no' => 'Ihr Paket endet am :date. Einen Datenexport möchten Sie nicht — laden Sie sich bis dahin selbst herunter, was Sie behalten wollen.',
+ // `null` heißt: nie gefragt (gekündigt vor dieser Ausrollung). Der Satz
+ // fragt dann, statt zu versprechen.
+ 'cancel_scheduled_body_open' => 'Ihr Paket endet am :date. Möchten Sie zum Laufzeitende einen Datenexport? Sagen Sie es uns bis dahin — ohne Antwort bereiten wir keinen vor.',
'cancel_title' => 'Paket kündigen?',
'cancel_body' => 'Ihr Paket wird zum Ende der Abrechnungsperiode gekündigt.',
diff --git a/lang/en/admin.php b/lang/en/admin.php
index b7f0c4b..235d72d 100644
--- a/lang/en/admin.php
+++ b/lang/en/admin.php
@@ -153,6 +153,18 @@ return [
'pending_change' => 'Moves to :plan on :date',
'by_plan' => 'By plan',
'instances_sub' => 'All provisioned cloud instances.',
+
+ // The only place where the answer from the cancellation dialogue
+ // (instances.export_wish) has a reader at all — see
+ // App\Livewire\Admin\Instances::render(). The export itself is not built;
+ // the section says exactly that rather than implying a queue that does
+ // not exist.
+ 'export_wish' => [
+ 'title' => 'Data export ordered',
+ 'sub' => 'Explicitly asked for when cancelling. The export is still done by hand — this says for whom, and by when.',
+ 'col_ends' => 'Term ends',
+ 'over' => 'due',
+ ],
'instances_label' => 'instances',
'hosts_sub' => 'Proxmox hosts and capacity.',
'storage_used' => 'Storage used',
diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php
index 61f1082..0472401 100644
--- a/lang/en/dashboard.php
+++ b/lang/en/dashboard.php
@@ -30,6 +30,7 @@ return [
'sheet' => 'Operating sheet · as of :when',
'title_running' => 'Your cloud is running.',
'title_pending' => 'Your cloud is being set up.',
+ 'title_ended' => 'Your cloud has ended.',
'instance_status' => [
'active' => 'All services running',
@@ -50,6 +51,20 @@ return [
'export_hint' => 'You can change your mind about this at any time until the end of the term.',
'export_yes' => 'Export wanted',
'export_no' => 'No export wanted',
+ // The third state: `null` means the question was never put (cancelled
+ // before this rollout). The card then ASKS instead of claiming an
+ // answer nobody gave, and says what happens if it stays open.
+ 'export_open' => 'You have not told us yet whether you want a data export. Without an answer we prepare none.',
+ ],
+
+ // The term is over and the address has been withdrawn (App\Actions\
+ // EndInstanceService). Until now such a customer fell into the same branch
+ // as somebody who never ordered anything — and read "Your cloud is being
+ // set up." with a "Book a package" button under it.
+ 'ended' => [
+ 'label' => 'Your term has ended',
+ 'body' => 'Your package ended on :date. Your Nextcloud has been closed off since then.',
+ 'order_again' => 'Book a new package',
],
'no_instance_label' => 'No instance yet',
diff --git a/lang/en/service_ending.php b/lang/en/service_ending.php
index 5a4cffb..cbd1cd8 100644
--- a/lang/en/service_ending.php
+++ b/lang/en/service_ending.php
@@ -8,5 +8,9 @@ return [
'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.',
+ // Says only what is true: the request is on record and somebody will get
+ // in touch. This sentence used to claim, in the present tense, that we
+ // were already preparing an export — there is none, it hangs on a storage
+ // server that does not exist yet.
+ 'export_prepared' => 'We have noted your request for a data export — we will come back to you about it. Either way, please download whatever you want to keep yourself.',
];
diff --git a/lang/en/settings.php b/lang/en/settings.php
index 94e677b..bcdeab6 100644
--- a/lang/en/settings.php
+++ b/lang/en/settings.php
@@ -58,7 +58,16 @@ return [
'no_package' => 'No active package.',
'cancel_cta' => 'Cancel package',
'cancel_scheduled_title' => 'Cancellation scheduled',
- 'cancel_scheduled_body' => 'Your package ends on :date. You will then receive your data export.',
+ // Three wordings for the three states of instances.export_wish. A single
+ // sentence used to stand here and promised the export to EVERYBODY —
+ // including the customer who had just deliberately chosen "no" in the
+ // cancellation dialogue and is redirected straight to this page
+ // (ConfirmCancelPackage::cancelPackage()).
+ 'cancel_scheduled_body_yes' => 'Your package ends on :date. Your request for a data export is noted — we will come back to you about it.',
+ 'cancel_scheduled_body_no' => 'Your package ends on :date. You do not want a data export — download whatever you want to keep before then.',
+ // `null` means never asked (cancelled before this rollout). The sentence
+ // then asks instead of promising.
+ 'cancel_scheduled_body_open' => 'Your package ends on :date. Would you like a data export at the end of the term? Tell us before then — without an answer we prepare none.',
'cancel_title' => 'Cancel package?',
'cancel_body' => 'Your package will be cancelled at the end of the billing period.',
diff --git a/resources/views/livewire/admin/instances.blade.php b/resources/views/livewire/admin/instances.blade.php
index 612a558..cf38a55 100644
--- a/resources/views/livewire/admin/instances.blade.php
+++ b/resources/views/livewire/admin/instances.blade.php
@@ -4,6 +4,48 @@
{{ __('admin.instances_sub') }}
+ {{-- Bestellte Datenexporte. Nur vorhanden, wenn wirklich einer bestellt
+ wurde — ein dauerhaft leerer Kasten wird nach der zweiten Woche nicht
+ mehr gelesen. Siehe Instances::render(): das ist der einzige Ort in
+ der Konsole, an dem die Antwort aus dem Kuendigungsdialog ueberhaupt
+ sichtbar wird. --}}
+ @if ($exports !== [])
+
@@ -126,22 +128,62 @@
{{-- Aufgabe 4: dieselbe Frage wie beim Kuendigen, hier mit
der Moeglichkeit, sie sich anders zu ueberlegen — bis
zum Laufzeitende (setExportWish() prueft das selbst
- nach). --}}
+ nach).
+
+ Bewusst DIESELBEN zwei Auswahlfelder wie im
+ Kuendigungsdialog (livewire/confirm-cancel-package),
+ nicht der Schalter, der hier stand. `export_wish`
+ fuehrt drei Zustaende, ein Schalter kennt zwei — und
+ `(bool) null` ist `false`, also stand unter dem
+ Streifen „Kein Export gewuenscht" als Antwort eines
+ Menschen, den niemand gefragt hatte. Ein
+ unbeantworteter Zustand zeigt hier schlicht keine
+ Auswahl an und fragt darueber im Klartext nach. --}}
@endif
- @if ($instance === null)
+ @if ($instance === null && $endedAt !== null)
+ {{-- Die abgelaufene Laufzeit — der dritte Fall, den es hier bisher
+ nicht gab. Ein Kunde, dessen Instanz auf `ended` steht, holt
+ Dashboard::render() nicht mehr; er fiel damit in den Zweig
+ darunter und las „Ihre Cloud wird eingerichtet." samt „Paket
+ buchen". Fuer jemanden, dem eben die Adresse eingezogen wurde
+ (EndInstanceService), ist das die falscheste Zeile der Seite.
+ Kein Kunde ohne Paket, sondern einer, dessen Paket vorbei ist —
+ mit dem Datum, an dem es endete. --}}
+
+
+
+ @elseif ($instance === null)
{{-- Said plainly rather than papered over with an empty record: a page
of dashes reads as broken, and "we are still setting it up" and
"you have not ordered yet" are different things. --}}
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php
index 32fcf95..8050936 100644
--- a/resources/views/livewire/settings.blade.php
+++ b/resources/views/livewire/settings.blade.php
@@ -373,7 +373,18 @@
@if ($cancellationScheduled)
- {{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }}
+ {{-- An die Antwort des Kunden gebunden, statt jedem
+ denselben Export zu versprechen. Der Kunde, der im
+ Dialog gerade „Nein" gewaehlt hat, wird direkt
+ hierher umgeleitet — und las als Erstes, dass er
+ ihn doch bekommt. Drei Zustaende, drei Saetze; bei
+ `null` (nie gefragt) fragt der Satz, statt zu
+ versprechen. --}}
+ {{ __(match ($instance?->export_wish) {
+ true => 'settings.cancel_scheduled_body_yes',
+ false => 'settings.cancel_scheduled_body_no',
+ default => 'settings.cancel_scheduled_body_open',
+ }, ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }}
@elseif ($hasActivePackage)
{{ __('settings.package_active', ['plan' => $instance ? __('billing.plan.'.$instance->plan) : '—']) }}
@else
diff --git a/resources/views/mail/service-ending-soon.blade.php b/resources/views/mail/service-ending-soon.blade.php
index c019e94..47b9f33 100644
--- a/resources/views/mail/service-ending-soon.blade.php
+++ b/resources/views/mail/service-ending-soon.blade.php
@@ -23,7 +23,9 @@
{{-- 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. --}}
+ Export anzukündigen wäre ein Versprechen, das niemand eingelöst hätte.
+ Und `null` heißt "nie gefragt": auch dafür ist der Satz falsch, deshalb
+ der strikte Vergleich auf true. --}}
@if ($exportWish === true)
diff --git a/tests/Feature/Cancellation/DashboardCountdownTest.php b/tests/Feature/Cancellation/DashboardCountdownTest.php
index 5502476..70c9051 100644
--- a/tests/Feature/Cancellation/DashboardCountdownTest.php
+++ b/tests/Feature/Cancellation/DashboardCountdownTest.php
@@ -87,13 +87,48 @@ it('zeigt den Streifen nicht, solange nicht gekuendigt wurde', function () {
it('zeigt ihn auch nach dem Laufzeitende nicht mehr', function () {
// Danach ist der Zugang zu, und ein Streifen, der eine Restzeit nennt,
- // wäre schlicht falsch. Was dann gilt, sagt die Seite an anderer Stelle.
+ // wäre schlicht falsch. Was dann gilt, sagt der Kasten aus der Prüfung
+ // darunter — bis zur Fix-Welle behauptete dieser Kommentar das, und die
+ // Seite sagte das Gegenteil.
[$user] = gekuendigteInstanz(endetIn: now()->subDay(), status: 'ended');
Livewire::actingAs($user)->test(Dashboard::class)
->assertDontSee(__('dashboard.ending.title'));
});
+it('sagt nach dem Laufzeitende, dass die Laufzeit beendet ist', function () {
+ // K2, zweite Hälfte: Dashboard::render() holt eine `ended`-Instanz nicht
+ // mehr, und der Kunde fiel damit in denselben Zweig wie jemand, der noch
+ // nie etwas bestellt hat — „Ihre Cloud wird eingerichtet." samt „Paket
+ // buchen", am Tag, an dem ihm EndInstanceService die Adresse eingezogen
+ // hat. Das ist kein Kunde ohne Paket, sondern einer, dessen Paket vorbei
+ // ist, und die Seite muss das sagen, samt dem Datum.
+ [$user, $instance] = gekuendigteInstanz(endetIn: now()->subDay(), status: 'ended');
+
+ Livewire::actingAs($user)->test(Dashboard::class)
+ ->assertSee(__('dashboard.ended.label'))
+ ->assertSee($instance->service_ends_at->local()->isoFormat('LL'))
+ ->assertDontSee(__('dashboard.title_pending'))
+ ->assertDontSee(__('dashboard.no_instance_label'));
+});
+
+it('zeigt einem Kunden ohne jede Instanz weiterhin den leeren Zustand', function () {
+ // Die Gegenprobe zur Prüfung darüber: der beendete Fall darf den ehrlichen
+ // Leerzustand nicht verschlucken. Wer nie etwas bestellt hat, bekommt
+ // weiterhin „Noch keine Instanz" — und keine Rede von einer Laufzeit, die
+ // es nie gab.
+ $user = User::factory()->create(['email' => 'ohne@example.test', 'email_verified_at' => now()]);
+ Customer::factory()->create([
+ 'email' => 'ohne@example.test',
+ 'user_id' => $user->id,
+ 'status' => 'active',
+ ]);
+
+ Livewire::actingAs($user)->test(Dashboard::class)
+ ->assertSee(__('dashboard.no_instance_label'))
+ ->assertDontSee(__('dashboard.ended.label'));
+});
+
it('nennt keine Restzeit, die schon abgelaufen ist', function () {
// Der Fall zwischen den Stühlen: das Laufzeitende ist vorbei, aber der
// stündliche Lauf hat die Instanz noch nicht auf `ended` gestellt. Eine
@@ -130,12 +165,58 @@ it('zaehlt am letzten Tag in Stunden, nicht mehr in Tagen', function () {
->assertSee(trans_choice('dashboard.ending.remaining_hours', 5));
});
-it('traegt die Antwort zum Export und laesst sie aendern', function () {
+/*
+ * Die drei Zustände am Streifen, je einer pro Prüfung.
+ *
+ * Vorher stand hier EINE Prüfung, und sie konnte nicht fehlschlagen:
+ * `x-ui.switch` rendert beide Wörter und überlässt dem CSS, welches sichtbar
+ * ist — `assertSee('Kein Export gewünscht')` war deshalb bei `true`, bei
+ * `false` UND bei `null` grün. Genau deshalb kam K1 durch: `(bool) null` ist
+ * `false`, der Schalter behauptete einem nie gefragten Menschen seine eigene
+ * Antwort, und nichts hielt die drei Zustände auseinander.
+ *
+ * Die Auswahlfelder tragen ihren Zustand dagegen im Markup (`checked`), also
+ * prüft das hier wirklich das, was der Kunde sieht.
+ */
+it('zeigt am Streifen ein Ja als Ja', function () {
+ [$user, $instance] = gekuendigteInstanz(endetIn: now()->addDays(12));
+ $instance->update(['export_wish' => true]);
+
+ Livewire::actingAs($user)->test(Dashboard::class)
+ ->assertSeeHtml('id="export-wish-yes" checked')
+ ->assertDontSeeHtml('id="export-wish-no" checked');
+});
+
+it('zeigt am Streifen ein Nein als Nein', function () {
+ [$user, $instance] = gekuendigteInstanz(endetIn: now()->addDays(12));
+ $instance->update(['export_wish' => false]);
+
+ Livewire::actingAs($user)->test(Dashboard::class)
+ ->assertSeeHtml('id="export-wish-no" checked')
+ ->assertDontSeeHtml('id="export-wish-yes" checked');
+});
+
+it('behauptet keine Antwort, wenn niemand gefragt wurde', function () {
+ // Der Schaden, gegen den der Kopfkommentar der Wanderung geschrieben
+ // wurde: eine vor dieser Ausrollung gekündigte Instanz trägt `null`, und
+ // der Streifen sagte darunter „Kein Export gewünscht" — eine Aussage über
+ // einen Wunsch, den dieser Mensch nie geäußert hat, dargestellt als seine
+ // eigene Antwort. Keine Auswahl ist markiert, und der Satz daneben fragt.
+ [$user, $instance] = gekuendigteInstanz(endetIn: now()->addDays(12));
+
+ expect($instance->export_wish)->toBeNull();
+
+ Livewire::actingAs($user)->test(Dashboard::class)
+ ->assertDontSeeHtml('id="export-wish-yes" checked')
+ ->assertDontSeeHtml('id="export-wish-no" checked')
+ ->assertSee(__('dashboard.ending.export_open'));
+});
+
+it('laesst die Antwort am Streifen aendern', function () {
[$user, $instance] = gekuendigteInstanz(endetIn: now()->addDays(12));
$instance->update(['export_wish' => false]);
Livewire::actingAs($user)->test(Dashboard::class)
- ->assertSee(__('dashboard.ending.export_no'))
->call('setExportWish', true);
expect($instance->fresh()->export_wish)->toBeTrue();
diff --git a/tests/Feature/Cancellation/ExportAnswerHasReadersTest.php b/tests/Feature/Cancellation/ExportAnswerHasReadersTest.php
new file mode 100644
index 0000000..718ed31
--- /dev/null
+++ b/tests/Feature/Cancellation/ExportAnswerHasReadersTest.php
@@ -0,0 +1,137 @@
+create(['email' => 'vertrag@example.test', 'email_verified_at' => now()]);
+
+ $customer = Customer::factory()->create([
+ 'email' => 'vertrag@example.test',
+ 'user_id' => $user->id,
+ 'status' => 'active',
+ ]);
+
+ $order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'team']);
+
+ $instance = Instance::factory()->create([
+ 'customer_id' => $customer->id,
+ 'order_id' => $order->id,
+ 'plan' => 'team',
+ 'status' => 'cancellation_scheduled',
+ 'subdomain' => 'vertrag',
+ 'cancel_requested_at' => now()->subDay(),
+ 'service_ends_at' => now()->addDays(20),
+ 'export_wish' => $exportWunsch,
+ ]);
+
+ return [$user, $instance->refresh()];
+}
+
+it('verspricht keinen Export, wenn der Kunde gerade Nein gesagt hat', function () {
+ // K2: zwei richtige Bauteile, ein Bildschirm dazwischen, der beiden
+ // widersprach. Der Dialog schreibt korrekt `false` und leitet auf genau
+ // diese Seite um — und das Erste, was der Kunde dort las, war, dass er
+ // den Export bekommt.
+ [$user, $instance] = kundeMitVorgemerkterKuendigung(false);
+
+ Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])
+ ->assertSee(__('settings.cancel_scheduled_body_no', [
+ 'date' => $instance->service_ends_at->local()->isoFormat('LL'),
+ ]))
+ ->assertDontSee(__('settings.cancel_scheduled_body_yes', [
+ 'date' => $instance->service_ends_at->local()->isoFormat('LL'),
+ ]));
+});
+
+it('nennt den Export nur dem, der ihn wollte', function () {
+ [$user, $instance] = kundeMitVorgemerkterKuendigung(true);
+
+ Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])
+ ->assertSee(__('settings.cancel_scheduled_body_yes', [
+ 'date' => $instance->service_ends_at->local()->isoFormat('LL'),
+ ]));
+});
+
+it('fragt auf der Vertragsseite nach, wenn niemand gefragt wurde', function () {
+ // `null` heißt: vor dieser Ausrollung gekündigt. Der Satz fragt dann,
+ // statt zu versprechen — und sagt, was passiert, wenn die Frage offen
+ // bleibt.
+ [$user, $instance] = kundeMitVorgemerkterKuendigung(null);
+
+ Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])
+ ->assertSee(__('settings.cancel_scheduled_body_open', [
+ 'date' => $instance->service_ends_at->local()->isoFormat('LL'),
+ ]))
+ ->assertDontSee(__('settings.cancel_scheduled_body_yes', [
+ 'date' => $instance->service_ends_at->local()->isoFormat('LL'),
+ ]));
+});
+
+it('zeigt dem Betreiber, wer einen Export bestellt hat und bis wann', function () {
+ // W1: ohne diesen Abschnitt hatte ein „Ja" in der ganzen Konsole keinen
+ // einzigen Leser — ein grep über app/ und die Konsolenansichten fand
+ // ausserhalb dieses Vorhabens keinen Treffer. Beides muss dastehen: wer,
+ // und wann dessen Laufzeit endet.
+ $customer = Customer::factory()->create(['name' => 'Exportkundin GmbH']);
+ $instance = Instance::factory()->create([
+ 'customer_id' => $customer->id,
+ 'status' => 'cancellation_scheduled',
+ 'subdomain' => 'exportkundin',
+ 'service_ends_at' => now()->addDays(9),
+ 'export_wish' => true,
+ ]);
+
+ Livewire::actingAs(admin(), 'operator')->test(AdminInstances::class)
+ ->assertSee(__('admin.export_wish.title'))
+ ->assertSee('Exportkundin GmbH')
+ ->assertSee($instance->service_ends_at->local()->isoFormat('LL'));
+});
+
+it('fuehrt in der Konsole niemanden auf, der keinen Export wollte', function () {
+ // Ein Nein und ein unbeantwortetes Null sind beide keine Bestellung. Der
+ // Abschnitt erscheint gar nicht erst — ein dauerhaft leerer Kasten wird
+ // nach der zweiten Woche nicht mehr gelesen. Geprüft an der Überschrift
+ // und nicht am Kundennamen: der steht ohnehin in der Instanzliste
+ // darunter, und eine Zusicherung darauf wäre aus dem falschen Grund rot.
+ $customer = Customer::factory()->create(['name' => 'Neinkundin GmbH']);
+ Instance::factory()->create([
+ 'customer_id' => $customer->id,
+ 'status' => 'cancellation_scheduled',
+ 'subdomain' => 'neinkundin',
+ 'service_ends_at' => now()->addDays(9),
+ 'export_wish' => false,
+ ]);
+ Instance::factory()->create([
+ 'status' => 'cancellation_scheduled',
+ 'subdomain' => 'niegefragt',
+ 'service_ends_at' => now()->addDays(9),
+ ]);
+
+ Livewire::actingAs(admin(), 'operator')->test(AdminInstances::class)
+ ->assertDontSee(__('admin.export_wish.title'));
+});
diff --git a/tests/Feature/Cancellation/RemindEndingServicesTest.php b/tests/Feature/Cancellation/RemindEndingServicesTest.php
index 2b13624..52c590f 100644
--- a/tests/Feature/Cancellation/RemindEndingServicesTest.php
+++ b/tests/Feature/Cancellation/RemindEndingServicesTest.php
@@ -120,3 +120,20 @@ it('trägt in der Mail das Datum und den Weg zum Selbst-Herunterladen', function
->and($text)->toContain($instance->service_ends_at->local()->isoFormat('LL'))
->and($text)->toContain('https://'.$instance->address(ProvisioningSettings::dnsZone()));
});
+
+it('behauptet keinen Vorgang, den niemand ausfuehrt', function () {
+ // W1: der Satz stand im Präsens und sagte, wir bereiteten bereits einen
+ // Export vor. Es gibt keinen — er hängt an einem Speicherserver, den es
+ // noch nicht gibt. Was stimmt, ist: der Wunsch ist vermerkt, und es
+ // meldet sich jemand.
+ $instance = Instance::factory()->create([
+ 'status' => 'cancellation_scheduled',
+ 'service_ends_at' => now()->addDays(6),
+ 'export_wish' => true,
+ ]);
+
+ $text = (new ServiceEndingSoonMail($instance))->render();
+
+ expect($text)->toContain('vermerkt')
+ ->and($text)->not->toContain('bereiten wir zusätzlich einen für Sie vor');
+});