Ein Streifen im Dashboard sagt, wie lange der Zugang noch steht
Eine Mail sieben Tage vorher kann im Postfach untergehen; die eigene Uebersicht oeffnet der Kunde ohnehin. Der Streifen nennt das Datum, die Restzeit und was danach geschieht — dass der Zugang zur Cloud endet, nicht dass irgendetwas geloescht wird. Das ist die Frist, die ihn betrifft. Und er traegt die Antwort zum Export: wer liest, dass die Zeit laeuft, will es sich im selben Atemzug anders ueberlegen koennen (Aufgabe 4) — bis zum Laufzeitende, danach nicht mehr, geprueft direkt an der Methode und nicht nur am Schalter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/versandtakt
parent
5eef03d267
commit
cfd1481797
|
|
@ -79,10 +79,86 @@ class Dashboard extends Component
|
||||||
// THIS customer above) rather than a fresh unscoped lookup, and
|
// THIS customer above) rather than a fresh unscoped lookup, and
|
||||||
// never stored as a public property — see acknowledgeCredentials().
|
// never stored as a public property — see acknowledgeCredentials().
|
||||||
'credentials' => $this->credentials($instance, $domain),
|
'credentials' => $this->credentials($instance, $domain),
|
||||||
|
// Die Frist, die den Kunden betrifft — wie lange SEIN Zugang noch
|
||||||
|
// steht, nicht wann irgendetwas geloescht wird. Siehe ending().
|
||||||
|
'ending' => $this->ending($instance),
|
||||||
'asOf' => Carbon::now(),
|
'asOf' => Carbon::now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aendert die Antwort zum Export, solange sie noch etwas bedeutet: bis
|
||||||
|
* zum Laufzeitende. Wie acknowledgeCredentials() loest diese Methode
|
||||||
|
* Kunde UND Instanz selbst neu auf, statt einer vom Browser hydrierten
|
||||||
|
* Eigenschaft zu vertrauen — sie ist ueber /livewire/update erreichbar,
|
||||||
|
* nicht nur ueber den Schalter im Streifen, und traegt deshalb dieselbe
|
||||||
|
* Bedingung wie ending() unten: verschwindet der Streifen, weil die
|
||||||
|
* Kuendigung nicht mehr aktiv ist oder das Laufzeitende vorbei ist, darf
|
||||||
|
* dieser Weg auch am Formular vorbei nichts mehr aendern.
|
||||||
|
*/
|
||||||
|
public function setExportWish(bool $wish): void
|
||||||
|
{
|
||||||
|
$customer = $this->requireCustomer();
|
||||||
|
if ($customer === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = $customer->instances()
|
||||||
|
->where('status', 'cancellation_scheduled')
|
||||||
|
->where('service_ends_at', '>', now())
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($instance === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance->update(['export_wish' => $wish]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was der Streifen ueber die Restfrist braucht — oder null, wenn er gar
|
||||||
|
* nicht erscheinen soll.
|
||||||
|
*
|
||||||
|
* Zeigt sich nur, wenn eine Kuendigung vorgemerkt ist UND das
|
||||||
|
* Laufzeitende noch in der Zukunft liegt. Der zweite Teil ist kein
|
||||||
|
* Sonderfall am Rand: der stuendliche Lauf, der eine abgelaufene Instanz
|
||||||
|
* auf `ended` stellt, laesst einen DNS-Fehler bewusst durch
|
||||||
|
* (EndInstanceService) und kann deshalb real tagelang hinterherhinken.
|
||||||
|
* Ohne diese Pruefung wuerde der Streifen in genau diesem Fenster eine
|
||||||
|
* abgelaufene Frist als Restzeit ausgeben — der Fehler, den niemand
|
||||||
|
* meldet, weil er wie eine Kleinigkeit aussieht, und den jeder sieht.
|
||||||
|
*
|
||||||
|
* @return array{ends_at: Carbon, days: int, hours: int, show_days: bool}|null
|
||||||
|
*/
|
||||||
|
private function ending(?Instance $instance): ?array
|
||||||
|
{
|
||||||
|
if ($instance === null || $instance->status !== 'cancellation_scheduled' || $instance->service_ends_at === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $instance->service_ends_at->isFuture()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nie negativ, auch nicht auf dem Papier: geklemmt, obwohl die
|
||||||
|
// Pruefung oben das im Normalfall schon ausschliesst — die Zeit
|
||||||
|
// zwischen der Berechnung hier und der Anzeige im Browser soll sich
|
||||||
|
// nie als "-1 Sekunde" durchschlagen koennen.
|
||||||
|
$seconds = max(0, $instance->service_ends_at->getTimestamp() - Carbon::now()->getTimestamp());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ends_at' => $instance->service_ends_at,
|
||||||
|
// Abgerundet auf ganze Tage, solange mehr als ein Tag bleibt —
|
||||||
|
// "noch 12 Tage" liest sich, "noch 11 Tage 7 Stunden" nicht. Am
|
||||||
|
// letzten Tag zaehlt sie Stunden; das ist der Moment, in dem die
|
||||||
|
// Zahl wirklich zaehlt.
|
||||||
|
'days' => intdiv($seconds, 86400),
|
||||||
|
'hours' => intdiv($seconds, 3600),
|
||||||
|
'show_days' => $seconds > 86400,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes the instance's stored admin password and stamps the
|
* Deletes the instance's stored admin password and stamps the
|
||||||
* acknowledgement — the customer has confirmed they noted it down.
|
* acknowledgement — the customer has confirmed they noted it down.
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,21 @@ return [
|
||||||
'cancellation_scheduled' => 'Kündigung vorgemerkt',
|
'cancellation_scheduled' => 'Kündigung vorgemerkt',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Der Streifen, der sagt, wie lange der Zugang noch steht — nicht, wann
|
||||||
|
// irgendetwas geloescht wird. Erscheint nur bei vorgemerkter Kuendigung
|
||||||
|
// mit Laufzeitende in der Zukunft (App\Livewire\Dashboard::ending()).
|
||||||
|
'ending' => [
|
||||||
|
'title' => 'Ihr Zugang endet',
|
||||||
|
'until' => 'Ab :date kommen Sie nicht mehr an Ihre Cloud.',
|
||||||
|
'remaining_days' => '{1} Noch 1 Tag.|[2,*] Noch :count Tage.',
|
||||||
|
'remaining_hours' => '{0} Weniger als eine Stunde.|{1} Noch 1 Stunde.|[2,*] Noch :count Stunden.',
|
||||||
|
'what_then' => 'Danach ist der Zugang zu Ihrer Nextcloud geschlossen. Laden Sie sich Ihre Daten bis dahin selbst herunter.',
|
||||||
|
'export_label' => 'Datenexport zum Laufzeitende',
|
||||||
|
'export_hint' => 'Sie können sich das bis zum Laufzeitende jederzeit anders überlegen.',
|
||||||
|
'export_yes' => 'Export gewünscht',
|
||||||
|
'export_no' => 'Kein Export gewünscht',
|
||||||
|
],
|
||||||
|
|
||||||
'no_instance_label' => 'Noch keine Instanz',
|
'no_instance_label' => 'Noch keine Instanz',
|
||||||
'no_instance_body' => 'Für dieses Konto läuft derzeit keine Cloud. Sobald eine Bestellung bezahlt ist, richten wir sie ein — dieser Bereich füllt sich dann von selbst.',
|
'no_instance_body' => 'Für dieses Konto läuft derzeit keine Cloud. Sobald eine Bestellung bezahlt ist, richten wir sie ein — dieser Bereich füllt sich dann von selbst.',
|
||||||
'no_instance_cta' => 'Paket wählen',
|
'no_instance_cta' => 'Paket wählen',
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,21 @@ return [
|
||||||
'cancellation_scheduled' => 'Cancellation scheduled',
|
'cancellation_scheduled' => 'Cancellation scheduled',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// The strip that says how long access still stands — not when anything
|
||||||
|
// gets deleted. Only appears once cancellation is scheduled and the end
|
||||||
|
// of the term is still in the future (App\Livewire\Dashboard::ending()).
|
||||||
|
'ending' => [
|
||||||
|
'title' => 'Your access ends',
|
||||||
|
'until' => 'From :date you will no longer be able to reach your cloud.',
|
||||||
|
'remaining_days' => '{1} 1 day left.|[2,*] :count days left.',
|
||||||
|
'remaining_hours' => '{0} Less than an hour left.|{1} 1 hour left.|[2,*] :count hours left.',
|
||||||
|
'what_then' => 'After that your Nextcloud is closed off. Download your data yourself before then.',
|
||||||
|
'export_label' => 'Data export at the end of the term',
|
||||||
|
'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',
|
||||||
|
],
|
||||||
|
|
||||||
'no_instance_label' => 'No instance yet',
|
'no_instance_label' => 'No instance yet',
|
||||||
'no_instance_body' => 'No cloud is running for this account at the moment. As soon as an order is paid we set one up — this area then fills itself.',
|
'no_instance_body' => 'No cloud is running for this account at the moment. As soon as an order is paid we set one up — this area then fills itself.',
|
||||||
'no_instance_cta' => 'Choose a package',
|
'no_instance_cta' => 'Choose a package',
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,45 @@
|
||||||
</section>
|
</section>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if ($ending)
|
||||||
|
{{-- Die Frist, die den Kunden betrifft: wie lange SEIN Zugang noch
|
||||||
|
steht — nicht, wann irgendetwas geloescht wird. Dieselbe
|
||||||
|
Warnfarbe und Bauform wie der Zugangsdaten-Kasten darueber, an
|
||||||
|
derselben Stelle im Fluss: eine Sprache fuer Dringlichkeit auf
|
||||||
|
dieser Seite, nicht zwei. --}}
|
||||||
|
<section class="rounded-lg border border-warning-border bg-warning-bg p-6 shadow-xs animate-rise">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning" aria-hidden="true">
|
||||||
|
<x-ui.icon name="calendar" class="size-5" />
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h2 class="font-semibold text-warning">{{ __('dashboard.ending.title') }}</h2>
|
||||||
|
<p class="mt-1 max-w-lg text-sm text-warning">
|
||||||
|
{{ __('dashboard.ending.until', ['date' => $ending['ends_at']->local()->locale($locale)->isoFormat('LL')]) }}
|
||||||
|
{{ $ending['show_days']
|
||||||
|
? trans_choice('dashboard.ending.remaining_days', $ending['days'])
|
||||||
|
: trans_choice('dashboard.ending.remaining_hours', $ending['hours']) }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 max-w-lg text-sm text-warning">{{ __('dashboard.ending.what_then') }}</p>
|
||||||
|
|
||||||
|
{{-- Aufgabe 4: dieselbe Frage wie beim Kuendigen, hier mit
|
||||||
|
der Moeglichkeit, sie sich anders zu ueberlegen — bis
|
||||||
|
zum Laufzeitende (setExportWish() prueft das selbst
|
||||||
|
nach). --}}
|
||||||
|
<div class="mt-4 max-w-sm rounded-lg border border-warning-border bg-surface px-3 py-2.5">
|
||||||
|
<x-ui.switch name="export_wish"
|
||||||
|
wire:click="setExportWish({{ $instance->export_wish ? 'false' : 'true' }})"
|
||||||
|
:checked="(bool) $instance->export_wish"
|
||||||
|
:label="__('dashboard.ending.export_label')"
|
||||||
|
:hint="__('dashboard.ending.export_hint')"
|
||||||
|
:on="__('dashboard.ending.export_yes')"
|
||||||
|
:off="__('dashboard.ending.export_no')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($instance === null)
|
@if ($instance === null)
|
||||||
{{-- Said plainly rather than papered over with an empty record: a page
|
{{-- 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
|
of dashes reads as broken, and "we are still setting it up" and
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
<?php // tests/Feature/Cancellation/DashboardCountdownTest.php
|
||||||
|
|
||||||
|
use App\Livewire\Dashboard;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\Instance;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\User;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Kunde mit einer gekündigten Instanz — nach dem Muster von
|
||||||
|
* kuendbareInstanz() (tests/Feature/Cancellation/CancelAsksAboutExportTest.php):
|
||||||
|
* Nutzer, Kunde, Bestellung, Instanz. Anders als dort schon im Zustand NACH
|
||||||
|
* der Kündigung, so wie ihn ConfirmCancelPackage::cancelPackage() hinterlässt
|
||||||
|
* — status und service_ends_at sind bereits gesetzt.
|
||||||
|
*
|
||||||
|
* @return array{0: User, 1: Instance}
|
||||||
|
*/
|
||||||
|
function gekuendigteInstanz(\Illuminate\Support\Carbon $endetIn, string $status = 'cancellation_scheduled'): array
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['email' => 'gekuendigt@example.test', 'email_verified_at' => now()]);
|
||||||
|
|
||||||
|
$customer = Customer::factory()->create([
|
||||||
|
'email' => 'gekuendigt@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' => $status,
|
||||||
|
'subdomain' => 'gekuendigt',
|
||||||
|
'cancel_requested_at' => now()->subDays(2),
|
||||||
|
'service_ends_at' => $endetIn,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$user, $instance->refresh()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Kunde mit einer laufenden Instanz, die niemand gekündigt hat —
|
||||||
|
* dieselbe Form wie gekuendigteInstanz(), nur ohne Kündigung.
|
||||||
|
*
|
||||||
|
* @return array{0: User, 1: Instance}
|
||||||
|
*/
|
||||||
|
function laufendeInstanz(): array
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['email' => 'laeuft@example.test', 'email_verified_at' => now()]);
|
||||||
|
|
||||||
|
$customer = Customer::factory()->create([
|
||||||
|
'email' => 'laeuft@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' => 'active',
|
||||||
|
'subdomain' => 'laeuft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$user, $instance->refresh()];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('zeigt nach einer Kuendigung, wie lange der Zugang noch steht', function () {
|
||||||
|
[$user, $instance] = gekuendigteInstanz(endetIn: now()->addDays(12));
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(Dashboard::class)
|
||||||
|
->assertSee(__('dashboard.ending.title'))
|
||||||
|
->assertSee($instance->service_ends_at->local()->isoFormat('LL'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt den Streifen nicht, solange nicht gekuendigt wurde', function () {
|
||||||
|
[$user] = laufendeInstanz();
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(Dashboard::class)
|
||||||
|
->assertDontSee(__('dashboard.ending.title'));
|
||||||
|
});
|
||||||
|
|
||||||
|
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.
|
||||||
|
[$user] = gekuendigteInstanz(endetIn: now()->subDay(), status: 'ended');
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(Dashboard::class)
|
||||||
|
->assertDontSee(__('dashboard.ending.title'));
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
// Zeile "noch -3 Stunden" ist die Sorte Fehler, die niemand meldet und
|
||||||
|
// jeder sieht.
|
||||||
|
//
|
||||||
|
// Korrektur gegenüber dem Zettel: `not->toContain('-')` über das ganze
|
||||||
|
// Dokument trägt nicht — ein Datum und eine CSS-Klasse enthalten ebenso
|
||||||
|
// Bindestriche, und die Prüfung wäre aus dem falschen Grund rot oder
|
||||||
|
// grün. Sie zielt stattdessen auf die Restzeit selbst: der Streifen darf
|
||||||
|
// gar nicht erst erscheinen, wenn das Laufzeitende vorbei ist — unabhängig
|
||||||
|
// davon, ob der Status das schon nachvollzogen hat. Genau das ist die
|
||||||
|
// Prüfung, die eine Implementierung ertappt, die sich allein auf den
|
||||||
|
// Status verlässt und das Datum ignoriert.
|
||||||
|
[$user] = gekuendigteInstanz(endetIn: now()->subHours(3));
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(Dashboard::class)
|
||||||
|
->assertDontSee(__('dashboard.ending.title'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nennt die Restzeit in ganzen Tagen, solange mehr als ein Tag bleibt', function () {
|
||||||
|
// Nicht im Zettel geprüft, aber ausdrücklich gefordert: "noch 12 Tage"
|
||||||
|
// liest sich, "noch 11 Tage 7 Stunden" nicht — abgerundet auf ganze Tage.
|
||||||
|
[$user] = gekuendigteInstanz(endetIn: now()->addDays(3)->addHours(5));
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(Dashboard::class)
|
||||||
|
->assertSee(trans_choice('dashboard.ending.remaining_days', 3));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zaehlt am letzten Tag in Stunden, nicht mehr in Tagen', function () {
|
||||||
|
[$user] = gekuendigteInstanz(endetIn: now()->addHours(5));
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(Dashboard::class)
|
||||||
|
->assertSee(trans_choice('dashboard.ending.remaining_hours', 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('traegt die Antwort zum Export und laesst sie 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laesst die Antwort nach dem Laufzeitende nicht mehr aendern', function () {
|
||||||
|
// Aufgabe 4: danach ist die Instanz `ended`, die Adresse eingezogen, und
|
||||||
|
// ob ein Export vorbereitet wurde, ist entschieden. Ein Aufruf, der dort
|
||||||
|
// noch etwas verspricht, wäre die nächste Attrappe — geprüft direkt gegen
|
||||||
|
// die Methode, nicht nur gegen den (dann ohnehin nicht mehr sichtbaren)
|
||||||
|
// Schalter im Streifen: setExportWish() ist über /livewire/update auch am
|
||||||
|
// Formular vorbei erreichbar.
|
||||||
|
[$user, $instance] = gekuendigteInstanz(endetIn: now()->subDay(), status: 'ended');
|
||||||
|
$instance->update(['export_wish' => false]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(Dashboard::class)
|
||||||
|
->call('setExportWish', true);
|
||||||
|
|
||||||
|
expect($instance->fresh()->export_wish)->toBeFalse();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue