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 !== []) +
+
+

+ {{ __('admin.export_wish.title') }} + {{ count($exports) }} +

+

{{ __('admin.export_wish.sub') }}

+
+
+ + + + + + + + + + @foreach ($exports as $e) + + + + + + @endforeach + +
{{ __('admin.col.customer') }}{{ __('admin.col.address') }}{{ __('admin.export_wish.col_ends') }}
{{ $e['customer'] }}{{ $e['address'] }} + {{ $e['ends_at'] }} + @if ($e['over']) + {{ __('admin.export_wish.over') }} + @endif +
+
+
+ @endif +
diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 14dc239..873aec8 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -14,7 +14,9 @@ {{ __('dashboard.sheet', ['when' => $asOf->local()->locale($locale)->isoFormat('LL, LT')]) }}

- {{ $instance !== null ? __('dashboard.title_running') : __('dashboard.title_pending') }} + {{ $instance !== null + ? __('dashboard.title_running') + : ($endedAt !== null ? __('dashboard.title_ended') : __('dashboard.title_pending')) }}

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

{{ __('dashboard.ending.export_label') }}

+

+ {{ $instance->export_wish === null + ? __('dashboard.ending.export_open') + : __('dashboard.ending.export_hint') }} +

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

{{ __('dashboard.ended.label') }}

+

+ {{ __('dashboard.ended.body', ['date' => $endedAt->local()->locale($locale)->isoFormat('LL')]) }} +

+
+ {{ __('dashboard.nav.support') }} + {{ __('dashboard.ended.order_again') }} +
+
+ @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'); +});