diff --git a/app/Http/Controllers/Web/TerminationController.php b/app/Http/Controllers/Web/TerminationController.php new file mode 100644 index 00000000..59d935d1 --- /dev/null +++ b/app/Http/Controllers/Web/TerminationController.php @@ -0,0 +1,115 @@ +input('website')) || filled($request->input('homepage'))) { + return $this->done(); + } + + // Eigener Error-Bag, damit sich das Kontakt-Modal im Hub-Footer + // (öffnet auf Default-Bag-Fehler) nicht mit öffnet. + $validated = $request->validateWithBag('termination', [ + 'kind' => ['required', 'in:ordentlich,ausserordentlich'], + 'reason' => ['required_if:kind,ausserordentlich', 'nullable', 'string', 'max:2000'], + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255'], + 'contract_reference' => ['nullable', 'string', 'max:255'], + 'terminate_mode' => ['required', 'in:asap,date'], + 'terminate_at' => ['required_if:terminate_mode,date', 'nullable', 'date'], + ], [ + 'reason.required_if' => __('Bitte geben Sie den Grund der außerordentlichen Kündigung an.'), + 'terminate_at.required_if' => __('Bitte geben Sie das gewünschte Beendigungsdatum an.'), + ]); + + $this->ensureNotRateLimited($request); + RateLimiter::hit($this->throttleKey($request), 3600); + + $terminationRequest = TerminationRequest::query()->create([ + 'kind' => $validated['kind'], + 'reason' => $validated['kind'] === 'ausserordentlich' ? ($validated['reason'] ?? null) : null, + 'name' => $validated['name'], + 'email' => $validated['email'], + 'contract_reference' => $validated['contract_reference'] ?? null, + 'terminate_at' => $validated['terminate_mode'] === 'date' ? $validated['terminate_at'] : null, + 'host' => $request->getHost(), + 'requester_ip' => $request->ip(), + ]); + + // § 312k Abs. 4 BGB: Zugang der Kündigungserklärung sofort auf + // elektronischem Wege bestätigen (Inhalt, Datum, Uhrzeit). + Mail::to($terminationRequest->email)->send(new TerminationRequestReceived($terminationRequest)); + Mail::to($this->operatorRecipient())->send(new TerminationRequestSubmitted($terminationRequest)); + + return $this->done(); + } + + private function done(): RedirectResponse + { + return back()->with('termination-status', __('Ihre Kündigung ist bei uns eingegangen. Sie erhalten umgehend eine automatische Eingangsbestätigung per E-Mail mit Datum und Uhrzeit des Zugangs.')); + } + + private function operatorRecipient(): string + { + if (str_contains(request()->getHost(), 'pressekonto')) { + $brand = config('domains.domains.pressekonto.brand', []); + } else { + $brand = WebPortalContext::fromRequest()->domainConfig['brand'] ?? []; + } + + return $brand['contact_email'] ?? config('mail.from.address'); + } + + private function ensureNotRateLimited(Request $request): void + { + if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) { + return; + } + + $seconds = RateLimiter::availableIn($this->throttleKey($request)); + + throw ValidationException::withMessages([ + 'email' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [ + 'minutes' => max(1, (int) ceil($seconds / 60)), + ]), + ])->errorBag('termination'); + } + + private function throttleKey(Request $request): string + { + return 'termination-form|'.$request->ip(); + } +} diff --git a/app/Mail/TerminationRequestReceived.php b/app/Mail/TerminationRequestReceived.php new file mode 100644 index 00000000..ab5829be --- /dev/null +++ b/app/Mail/TerminationRequestReceived.php @@ -0,0 +1,36 @@ +terminationRequest->created_at->format('d.m.Y'), + ); + } + + public function content(): Content + { + return new Content(view: 'emails.termination.received'); + } +} diff --git a/app/Mail/TerminationRequestSubmitted.php b/app/Mail/TerminationRequestSubmitted.php new file mode 100644 index 00000000..f18ccf74 --- /dev/null +++ b/app/Mail/TerminationRequestSubmitted.php @@ -0,0 +1,35 @@ +terminationRequest->id.'): '.$this->terminationRequest->name, + ); + } + + public function content(): Content + { + return new Content(view: 'emails.termination.submitted'); + } +} diff --git a/app/Models/TerminationRequest.php b/app/Models/TerminationRequest.php new file mode 100644 index 00000000..c28ba71b --- /dev/null +++ b/app/Models/TerminationRequest.php @@ -0,0 +1,60 @@ + */ + use HasFactory; + + protected $fillable = [ + 'kind', + 'reason', + 'name', + 'email', + 'contract_reference', + 'terminate_at', + 'host', + 'requester_ip', + 'status', + 'resolved_at', + ]; + + protected function casts(): array + { + return [ + 'terminate_at' => 'date', + 'resolved_at' => 'datetime', + ]; + } + + public function scopeOpen(Builder $query): Builder + { + return $query->where('status', 'open'); + } + + public function isExtraordinary(): bool + { + return $this->kind === 'ausserordentlich'; + } + + public function kindLabel(): string + { + return $this->isExtraordinary() ? 'Außerordentliche Kündigung' : 'Ordentliche Kündigung'; + } + + public function terminateAtLabel(): string + { + return $this->terminate_at?->format('d.m.Y') ?? 'zum nächstmöglichen Zeitpunkt'; + } +} diff --git a/database/factories/TerminationRequestFactory.php b/database/factories/TerminationRequestFactory.php new file mode 100644 index 00000000..a5634c75 --- /dev/null +++ b/database/factories/TerminationRequestFactory.php @@ -0,0 +1,47 @@ + + */ +class TerminationRequestFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'kind' => 'ordentlich', + 'reason' => null, + 'name' => fake()->name(), + 'email' => fake()->safeEmail(), + 'contract_reference' => null, + 'terminate_at' => null, + 'host' => 'pressekonto.test', + 'requester_ip' => fake()->ipv4(), + 'status' => 'open', + 'resolved_at' => null, + ]; + } + + public function extraordinary(): static + { + return $this->state(fn (): array => [ + 'kind' => 'ausserordentlich', + 'reason' => fake()->sentence(), + ]); + } + + public function resolved(): static + { + return $this->state(fn (): array => [ + 'status' => 'resolved', + 'resolved_at' => now(), + ]); + } +} diff --git a/database/migrations/2026_07_03_132503_create_termination_requests_table.php b/database/migrations/2026_07_03_132503_create_termination_requests_table.php new file mode 100644 index 00000000..a226bca5 --- /dev/null +++ b/database/migrations/2026_07_03_132503_create_termination_requests_table.php @@ -0,0 +1,48 @@ +id(); + + $table->string('kind', 40)->default('ordentlich'); + $table->text('reason')->nullable(); + + $table->string('name'); + $table->string('email'); + $table->string('contract_reference')->nullable(); + + $table->date('terminate_at')->nullable(); + + $table->string('host')->nullable(); + $table->string('requester_ip', 45)->nullable(); + + $table->string('status', 40)->default('open'); + $table->timestamp('resolved_at')->nullable(); + + $table->timestamps(); + + $table->index(['status', 'created_at']); + $table->index('email'); + }); + } + + public function down(): void + { + Schema::dropIfExists('termination_requests'); + } +}; diff --git a/resources/views/emails/termination/received.blade.php b/resources/views/emails/termination/received.blade.php new file mode 100644 index 00000000..913ef68f --- /dev/null +++ b/resources/views/emails/termination/received.blade.php @@ -0,0 +1,55 @@ + + + + + Eingangsbestätigung Ihrer Kündigung + + + +

Guten Tag {{ $terminationRequest->name }},

+ +

+ hiermit bestätigen wir den Eingang Ihrer Kündigungserklärung. Ihre Erklärung ist bei uns + am {{ $terminationRequest->created_at->format('d.m.Y') }} + um {{ $terminationRequest->created_at->format('H:i') }} Uhr eingegangen. +

+ +
+
Art der Kündigung
+
{{ $terminationRequest->kindLabel() }}
+ @if ($terminationRequest->isExtraordinary() && $terminationRequest->reason) +
Kündigungsgrund
+
{{ $terminationRequest->reason }}
+ @endif +
Name
+
{{ $terminationRequest->name }}
+
E-Mail
+
{{ $terminationRequest->email }}
+ @if ($terminationRequest->contract_reference) +
Vertrags-/Rechnungsnummer
+
{{ $terminationRequest->contract_reference }}
+ @endif +
Gewünschte Beendigung
+
{{ $terminationRequest->terminateAtLabel() }}
+
Vorgangsnummer
+
K-{{ $terminationRequest->id }}
+
+ +

+ Wir ordnen Ihre Kündigung dem zugehörigen Vertrag zu und bestätigen Ihnen + schnellstmöglich den Zeitpunkt, zu dem das Vertragsverhältnis endet. +

+ +

+ Diese Bestätigung wurde automatisch erstellt und dokumentiert Inhalt sowie Datum + und Uhrzeit des Zugangs Ihrer Kündigungserklärung (§ 312k BGB). + Bitte bewahren Sie sie auf. +

+ + diff --git a/resources/views/emails/termination/submitted.blade.php b/resources/views/emails/termination/submitted.blade.php new file mode 100644 index 00000000..f92d0638 --- /dev/null +++ b/resources/views/emails/termination/submitted.blade.php @@ -0,0 +1,43 @@ + + + + + Neue Online-Kündigung + + + +

Über den Kündigungsbutton auf {{ $terminationRequest->host }} ist eine neue Kündigung eingegangen:

+ +
+
Vorgangsnummer
+
K-{{ $terminationRequest->id }}
+
Art der Kündigung
+
{{ $terminationRequest->kindLabel() }}
+ @if ($terminationRequest->isExtraordinary()) +
Kündigungsgrund
+
{{ $terminationRequest->reason ?: '— nicht angegeben —' }}
+ @endif +
Name
+
{{ $terminationRequest->name }}
+
E-Mail
+
{{ $terminationRequest->email }}
+
Vertrags-/Rechnungsnummer
+
{{ $terminationRequest->contract_reference ?: '— nicht angegeben —' }}
+
Gewünschte Beendigung
+
{{ $terminationRequest->terminateAtLabel() }}
+
Eingegangen
+
{{ $terminationRequest->created_at->format('d.m.Y H:i') }} Uhr
+
+ +

+ Die Zugangsbestätigung an den Verbraucher wurde automatisch versendet (§ 312k Abs. 4 BGB). + Bitte den Vorgang dem Vertrag zuordnen und dem Kunden den Beendigungszeitpunkt bestätigen. +

+ + diff --git a/resources/views/livewire/admin/legal-requests/index.blade.php b/resources/views/livewire/admin/legal-requests/index.blade.php index 83fd7d18..4d3090c3 100644 --- a/resources/views/livewire/admin/legal-requests/index.blade.php +++ b/resources/views/livewire/admin/legal-requests/index.blade.php @@ -3,6 +3,7 @@ use App\Enums\LegalRequestStatus; use App\Enums\LegalRequestType; use App\Models\LegalRequest; +use App\Models\TerminationRequest; use App\Models\WithdrawalRequest; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -23,6 +24,9 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten #[Url(as: 'widerrufe', except: 'open')] public string $withdrawalStatusFilter = 'open'; + #[Url(as: 'kuendigungen', except: 'open')] + public string $terminationStatusFilter = 'open'; + public function updatedStatusFilter(): void { $this->resetPage(); @@ -38,6 +42,11 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten $this->resetPage('withdrawals'); } + public function updatedTerminationStatusFilter(): void + { + $this->resetPage('terminations'); + } + /** * Widerruf als erledigt markieren (Rückabwicklung angestoßen/abgeschlossen). */ @@ -57,6 +66,26 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten ]); } + /** + * Kündigung (§ 312k) als erledigt markieren (Vertrag beendet, Kunde hat + * die Bestätigung des Beendigungszeitpunkts erhalten). + */ + public function resolveTermination(int $id): void + { + TerminationRequest::query()->findOrFail($id)->update([ + 'status' => 'resolved', + 'resolved_at' => now(), + ]); + } + + public function reopenTermination(int $id): void + { + TerminationRequest::query()->findOrFail($id)->update([ + 'status' => 'open', + 'resolved_at' => null, + ]); + } + public function with(): array { $requests = LegalRequest::query() @@ -73,6 +102,12 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten ->latest() ->paginate(25, pageName: 'withdrawals'); + $terminations = TerminationRequest::query() + ->when($this->terminationStatusFilter === 'open', fn ($query) => $query->open()) + ->when($this->terminationStatusFilter === 'resolved', fn ($query) => $query->where('status', 'resolved')) + ->latest() + ->paginate(25, pageName: 'terminations'); + return [ 'requests' => $requests, 'typeOptions' => LegalRequestType::cases(), @@ -80,6 +115,8 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten 'totalCount' => LegalRequest::query()->count(), 'withdrawals' => $withdrawals, 'openWithdrawalCount' => WithdrawalRequest::query()->open()->count(), + 'terminations' => $terminations, + 'openTerminationCount' => TerminationRequest::query()->open()->count(), ]; } @@ -100,11 +137,11 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten :badge="__('Admin Backend')" :eyebrow="__('Administration · Recht & Compliance')" :title="__('Recht & Compliance')" - :description="__('DSGVO-Anonymisierung, Persönlichkeitsrechte und Meldungen zu Pressemitteilungen sowie Online-Widerrufe über den Widerrufsbutton – manuelle Bearbeitung, keine KI.')" + :description="__('DSGVO-Anonymisierung, Persönlichkeitsrechte und Meldungen zu Pressemitteilungen sowie Online-Widerrufe (Widerrufsbutton) und Online-Kündigungen (Kündigungsbutton § 312k BGB) – manuelle Bearbeitung, keine KI.')" /> {{-- ============== KPI-Reihe ============== --}} -
+
{{ __('zu bearbeiten') }} {{ __('offen + in Bearbeitung') }} @@ -117,6 +154,10 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten {{ __('Online-Widerruf') }} {{ __('gesetzliche Frist beachten') }} + + {{ __('Kündigungsbutton § 312k') }} + {{ __('Beendigungszeitpunkt bestätigen') }} +
{{-- ============== FILTER ============== --}} @@ -314,4 +355,120 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten + + {{-- ============== KÜNDIGUNGEN (Kündigungsbutton § 312k BGB) ============== --}} +
+
+ {{ __('Kündigungen') }} +
+ + + + + + + {{ __(':count Einträge', ['count' => $terminations->total()]) }} + +
+
+ + + {{ __('Vorgang') }} + {{ __('Art') }} + {{ __('Name') }} + {{ __('E-Mail') }} + {{ __('Beendigung') }} + {{ __('Eingegangen') }} + {{ __('Status') }} + {{ __('Aktion') }} + + + + @forelse ($terminations as $termination) + + + + K-{{ $termination->id }} + + + + + {{ $termination->isExtraordinary() ? __('Außerordentlich') : __('Ordentlich') }} + + @if ($termination->isExtraordinary() && $termination->reason) +

+ {{ \Illuminate\Support\Str::limit($termination->reason, 90) }} +

+ @endif +
+ + + {{ $termination->name }} + + @if ($termination->contract_reference) +

+ {{ __('Vertrag/Rechnung:') }} {{ $termination->contract_reference }} +

+ @endif +
+ + + {{ $termination->email }} + + + + + {{ $termination->terminate_at?->format('d.m.Y') ?? __('nächstmöglich') }} + + + + + {{ $termination->created_at?->format('d.m.Y H:i') ?? '-' }} + + + + @if ($termination->status === 'open') + {{ __('Offen') }} + @else + {{ __('Erledigt') }} + @endif + + + @if ($termination->status === 'open') + + {{ __('Erledigt') }} + + @else + + {{ __('Wieder öffnen') }} + + @endif + +
+ @empty + + +
+
+ +
+
+ {{ __('Keine Kündigungen in dieser Ansicht') }} +
+

+ {{ __('Online-Kündigungen über den Kündigungsbutton (§ 312k BGB) laufen hier auf. Die Zugangsbestätigung an den Verbraucher wird automatisch versendet – der Beendigungszeitpunkt muss manuell bestätigt werden.') }} +

+
+
+
+ @endforelse +
+
+ + +
diff --git a/routes/web.php b/routes/web.php index b35f4fa1..bc4b13c7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Web\NewsroomsController; use App\Http\Controllers\Web\PreiseController; use App\Http\Controllers\Web\ReleaseDetailController; use App\Http\Controllers\Web\SucheController; +use App\Http\Controllers\Web\TerminationController; use App\Http\Controllers\Web\VeroeffentlichenController; use App\Http\Middleware\SetEdition; use App\Models\Category; @@ -205,6 +206,13 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func Route::view('presse', 'web.presse')->name('presse'); Route::view('hilfe', 'web.hilfe')->name('hilfe'); Route::view('impressum', 'web.impressum')->name('impressum'); + + // Gesetzlicher Kündigungsbutton (§ 312k BGB): öffentliche Bestätigungsseite + // + Eingang der Kündigungserklärung, ohne Login erreichbar. + Route::get('kuendigung', [TerminationController::class, 'show'])->name('kuendigung'); + Route::post('kuendigung', [TerminationController::class, 'store']) + ->middleware('throttle:10,1') + ->name('kuendigung.store'); Route::view('datenschutz', 'web.datenschutz')->name('datenschutz'); Route::view('agb', 'web.agb')->name('agb'); Route::view('cookies', 'web.cookies')->name('cookies'); diff --git a/tests/Feature/Admin/TerminationRequestsAdminTest.php b/tests/Feature/Admin/TerminationRequestsAdminTest.php new file mode 100644 index 00000000..792a02dc --- /dev/null +++ b/tests/Feature/Admin/TerminationRequestsAdminTest.php @@ -0,0 +1,77 @@ +seed(RolesAndPermissionsSeeder::class); +}); + +function terminationAdmin(): User +{ + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + return $admin; +} + +test('the legal queue lists open termination requests', function () { + $termination = TerminationRequest::factory()->extraordinary()->create([ + 'name' => 'Paula Presse', + 'email' => 'paula@example.test', + 'contract_reference' => 'STR-2026-0042', + 'reason' => 'Wiederholte Störung der Leistung.', + ]); + + Volt::actingAs(terminationAdmin()) + ->test('admin.legal-requests.index') + ->assertOk() + ->assertSee('Kündigungen') + ->assertSee('K-'.$termination->id) + ->assertSee('Paula Presse') + ->assertSee('paula@example.test') + ->assertSee('STR-2026-0042') + ->assertSee('Außerordentlich'); +}); + +test('an admin can resolve a termination request', function () { + $termination = TerminationRequest::factory()->create(); + + Volt::actingAs(terminationAdmin()) + ->test('admin.legal-requests.index') + ->call('resolveTermination', $termination->id) + ->assertOk(); + + $termination->refresh(); + + expect($termination->status)->toBe('resolved'); + expect($termination->resolved_at)->not->toBeNull(); +}); + +test('an admin can reopen a resolved termination request', function () { + $termination = TerminationRequest::factory()->resolved()->create(); + + Volt::actingAs(terminationAdmin()) + ->test('admin.legal-requests.index') + ->set('terminationStatusFilter', 'all') + ->call('reopenTermination', $termination->id) + ->assertOk(); + + $termination->refresh(); + + expect($termination->status)->toBe('open'); + expect($termination->resolved_at)->toBeNull(); +}); + +test('resolved terminations are hidden from the default open view', function () { + TerminationRequest::factory()->resolved()->create(['name' => 'Erledigte Kündigung']); + + Volt::actingAs(terminationAdmin()) + ->test('admin.legal-requests.index') + ->assertOk() + ->assertDontSee('Erledigte Kündigung'); +}); diff --git a/tests/Feature/Web/TerminationRequestTest.php b/tests/Feature/Web/TerminationRequestTest.php new file mode 100644 index 00000000..0dcddafd --- /dev/null +++ b/tests/Feature/Web/TerminationRequestTest.php @@ -0,0 +1,143 @@ +get('https://pressekonto.test/')->assertSuccessful(); + + expect($response->getContent()) + ->toContain('Verträge hier kündigen') + ->toContain(route('kuendigung', ['edition' => 'de'])); +}); + +test('the termination confirmation page renders the § 312k form with the hub shell', function () { + /** @var TestCase $this */ + $response = $this->get('https://pressekonto.test/de/kuendigung')->assertSuccessful(); + $content = $response->getContent(); + + expect($content) + // Pflichtangaben der Bestätigungsseite (§ 312k Abs. 2 BGB). + ->toContain('Ordentliche Kündigung') + ->toContain('Außerordentliche Kündigung') + ->toContain('name="kind"') + ->toContain('name="reason"') + ->toContain('name="terminate_mode"') + ->toContain('Zum nächstmöglichen Zeitpunkt') + // Bestätigungsschaltfläche mit gesetzeskonformer Aufschrift. + ->toContain('jetzt kündigen') + // Hub-Gerüst (host-aware static-page). + ->toContain('Publisher · Hub'); +}); + +test('an ordinary termination is persisted and both mails are sent immediately', function () { + /** @var TestCase $this */ + Mail::fake(); + config()->set('domains.domains.pressekonto.brand.contact_email', 'kontakt@pressekonto.de'); + + $this->from('https://pressekonto.test/de/kuendigung') + ->post('https://pressekonto.test/de/kuendigung', [ + 'kind' => 'ordentlich', + 'name' => 'Paula Presse', + 'email' => 'paula@example.test', + 'contract_reference' => 'STR-2026-0042', + 'terminate_mode' => 'asap', + ]) + ->assertRedirect('https://pressekonto.test/de/kuendigung') + ->assertSessionHas('termination-status'); + + $request = TerminationRequest::query()->firstOrFail(); + + expect($request->kind)->toBe('ordentlich'); + expect($request->reason)->toBeNull(); + expect($request->name)->toBe('Paula Presse'); + expect($request->email)->toBe('paula@example.test'); + expect($request->contract_reference)->toBe('STR-2026-0042'); + expect($request->terminate_at)->toBeNull(); + expect($request->host)->toBe('pressekonto.test'); + expect($request->status)->toBe('open'); + + // § 312k Abs. 4 BGB: sofortige automatische Zugangsbestätigung. + Mail::assertSent(TerminationRequestReceived::class, fn (TerminationRequestReceived $mail) => $mail->hasTo('paula@example.test')); + Mail::assertSent(TerminationRequestSubmitted::class, fn (TerminationRequestSubmitted $mail) => $mail->hasTo('kontakt@pressekonto.de')); +}); + +test('an extraordinary termination requires a reason', function () { + /** @var TestCase $this */ + Mail::fake(); + + $this->post('https://pressekonto.test/de/kuendigung', [ + 'kind' => 'ausserordentlich', + 'name' => 'Paula Presse', + 'email' => 'paula@example.test', + 'terminate_mode' => 'asap', + ])->assertSessionHasErrorsIn('termination', ['reason']); + + expect(TerminationRequest::query()->count())->toBe(0); + Mail::assertNothingSent(); + + $this->post('https://pressekonto.test/de/kuendigung', [ + 'kind' => 'ausserordentlich', + 'reason' => 'Wiederholte Störung der Leistung.', + 'name' => 'Paula Presse', + 'email' => 'paula@example.test', + 'terminate_mode' => 'asap', + ])->assertSessionHas('termination-status'); + + expect(TerminationRequest::query()->firstOrFail()->reason)->toBe('Wiederholte Störung der Leistung.'); +}); + +test('a termination to a specific date requires and persists the date', function () { + /** @var TestCase $this */ + Mail::fake(); + + $this->post('https://pressekonto.test/de/kuendigung', [ + 'kind' => 'ordentlich', + 'name' => 'Paula Presse', + 'email' => 'paula@example.test', + 'terminate_mode' => 'date', + ])->assertSessionHasErrorsIn('termination', ['terminate_at']); + + $this->post('https://pressekonto.test/de/kuendigung', [ + 'kind' => 'ordentlich', + 'name' => 'Paula Presse', + 'email' => 'paula@example.test', + 'terminate_mode' => 'date', + 'terminate_at' => '2026-08-31', + ])->assertSessionHas('termination-status'); + + expect(TerminationRequest::query()->firstOrFail()->terminate_at->format('Y-m-d'))->toBe('2026-08-31'); +}); + +test('termination validation errors land in the termination error bag', function () { + /** @var TestCase $this */ + Mail::fake(); + + $this->post('https://pressekonto.test/de/kuendigung', [ + 'name' => '', + 'email' => 'keine-mail', + ])->assertSessionHasErrorsIn('termination', ['kind', 'name', 'email', 'terminate_mode']); + + expect(TerminationRequest::query()->count())->toBe(0); + Mail::assertNothingSent(); +}); + +test('honeypot submissions are silently accepted without persisting or mailing', function () { + /** @var TestCase $this */ + Mail::fake(); + + $this->post('https://pressekonto.test/de/kuendigung', [ + 'kind' => 'ordentlich', + 'name' => 'Bot', + 'email' => 'bot@example.test', + 'terminate_mode' => 'asap', + 'website' => 'https://spam.example', + ])->assertSessionHas('termination-status'); + + expect(TerminationRequest::query()->count())->toBe(0); + Mail::assertNothingSent(); +});