presseportale/tests/Feature/LegalRequestTest.php
Kevin Adametz 9afbedddeb recht: Meldeverfahren v2 Phasen 5+7 — DSGVO-Strukturformular, 10-€-Checkout mit KI-Vorprüfung, Triage & Panel-Modal
Phase 7 (Spur D, E2 revidiert): KI-Vorprüfung VOR der Zahlung im Modal —
rot lehnt ohne Zahlung ab, gelb/grün führt zum Stripe-Checkout
(SinglePurchaseType::Change, 10 € netto), nach Zahlungseingang übernimmt
der Webhook die geprüfte Fassung sofort (applyPaid: Snapshot, Sperre,
Verlauf). Nur einmal je PM; Obergrenze legal.change_max_distance_percent
(50 %) verhindert die verdeckte Selbst-Depublizierung.

Phase 5 (Spur B): strukturiertes Schwärzungsformular mit Pflicht-Vorschau
im verifizierten Web-Bereich UND als Modal im Kunden-Panel; Admin-Editor
wird aus payload.redaction vorbefüllt. Umsetzungsmail LegalRequestExecuted
an Antragsteller, Bestätigungsmail PressReleaseChangeApplied (Spur A/D,
neuer abbestellbarer Typ press_release_corrected).

Außerdem:
- Re-Sync-Sperre + Sitemap-Invalidierung fest in depublish() verankert
  (Lücke im bezahlten Webhook-Pfad geschlossen)
- Öffentliche „Rechtliches"-Seite als zweistufige Triage: primär der
  Portal-Anmeldelink, Formular nur Fallback mit Identifizierungs-Hinweis;
  Store auf report/dsgvo/personal_rights gehärtet
- Panel-Button „Ändern/Melden" öffnet Anliegen-Modal (4 Spuren) statt
  des Web-Formulars
- Pressekontakt-Fallback für Legacy-Bestände (30k PMs ohne Verknüpfung):
  Detailseite + pressContactEmail() nutzen PM-Kontakt → Firmen-Kontakt →
  Firmendaten
- Korrektur-Gate entity-fest (Editor-Normalisierung zählt nicht als
  Änderung), toEditorHtml() in Admin-Edit + Korrektur-Panel,
  Wochenlimit-Hinweis in Text und Bremsen-Meldung

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:02:57 +00:00

268 lines
10 KiB
PHP

<?php
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\Portal;
use App\Mail\LegalRequestReceived;
use App\Mail\LegalRequestSubmitted;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Livewire\Volt\Volt;
use Tests\TestCase;
beforeEach(function () {
$this->seed(RolesAndPermissionsSeeder::class);
});
/**
* Default-Testhost ist „localhost" → der Controller löst auf Businessportal24
* auf. Daher die PM bewusst für dieses Portal veröffentlichen.
*/
function publishedRelease(): PressRelease
{
return PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->create();
}
test('the legal request form renders with per-type hints and hidden honeypots', function () {
/** @var TestCase $this */
$release = publishedRelease();
$this->get(route('legal-request.create', ['slug' => $release->slug, 'type' => 'report']))
->assertOk()
->assertSee($release->title)
// Hinweistexte je Typ sind serverseitig vorhanden (Umschaltung via Alpine);
// finaler Wortlaut folgt dem Meldeverfahren v0.1 (R2.5, 10.07.2026).
->assertSee('Art. 17 DSGVO')
->assertSee('Persönlichkeitsrecht')
->assertSee('Eingangsbestätigung mit Vorgangsnummer')
->assertSee('kostenlos')
// Querverweis auf die Meldeverfahren-Übersicht.
->assertSee(route('melden'))
// Versteckte Honeypot-Felder im DOM.
->assertSee('name="website"', false)
->assertSee('name="homepage"', false);
});
test('the triage offers the portal magic-link path', function () {
/** @var TestCase $this */
$release = publishedRelease();
$this->get(route('legal-request.create', ['slug' => $release->slug, 'type' => 'dsgvo']))
->assertOk()
// Stufe 1: Anliegen-Karten inkl. Absender-Selbstservice.
->assertSee('Korrektur, Änderung oder Depublizierung')
->assertSee('Rechtswidrige Inhalte melden')
// Passwortloser Anmeldelink ins Portal (PMs werden im Konto zusammengeführt).
->assertSee('Anmeldelink im Portal anfordern')
->assertSee(rtrim(config('domains.domain_portal_url', config('app.url')), '/').'/anmeldelink', false)
// Fallback nur mit Identifizierungs-Hinweis.
->assertSee('persönliche Identifizierung erforderlich')
// Vorauswahl aus dem Query-Parameter.
->assertSee("concern: 'dsgvo'", false);
});
test('owner concerns preselect the self-service card', function () {
/** @var TestCase $this */
$release = publishedRelease();
$this->get(route('legal-request.create', ['slug' => $release->slug, 'type' => 'depublication']))
->assertOk()
->assertSee("concern: 'owner'", false);
});
test('owner track types are rejected by the public store endpoint', function () {
/** @var TestCase $this */
Mail::fake();
$release = publishedRelease();
$this->from(route('legal-request.create', ['slug' => $release->slug]))
->post(route('legal-request.store', ['slug' => $release->slug]), [
'type' => 'correction',
'message' => 'Bitte den Titel dieser Mitteilung komplett austauschen.',
])->assertSessionHasErrors('type');
expect(LegalRequest::query()->count())->toBe(0);
});
test('the report intake prefills the reported url from the query string', function () {
/** @var TestCase $this */
$detailUrl = 'https://presseecho.test/de/press-release/beispiel-pm';
$this->get(route('melden.pm', ['edition' => 'de', 'reported_url' => $detailUrl]))
->assertOk()
->assertSee('value="'.$detailUrl.'"', false);
});
test('the form 404s for a non-published release', function () {
/** @var TestCase $this */
$draft = PressRelease::factory()->forPortal(Portal::Businessportal24)->create();
$this->get(route('legal-request.create', ['slug' => $draft->slug]))
->assertNotFound();
});
test('a dsgvo request is stored in the queue', function () {
/** @var TestCase $this */
Mail::fake();
$release = publishedRelease();
$this->post(route('legal-request.store', ['slug' => $release->slug]), [
'type' => LegalRequestType::Dsgvo->value,
'requester_email' => 'betroffen@example.test',
'message' => 'Bitte meine personenbezogenen Daten anonymisieren.',
])->assertRedirect(route('release.detail', ['slug' => $release->slug]))
->assertSessionHas('legal-status');
$request = LegalRequest::query()->firstOrFail();
expect($request->type)->toBe(LegalRequestType::Dsgvo);
expect($request->status)->toBe(LegalRequestStatus::Open);
expect($request->press_release_id)->toBe($release->id);
expect($request->requester_email)->toBe('betroffen@example.test');
expect($request->portal)->toBe(Portal::Businessportal24->value);
// T10: Vorgangsnummer im Hinweis + Eingangsbestätigung/Queue-Hinweis.
expect(session('legal-status'))->toContain($request->reference());
Mail::assertQueued(LegalRequestReceived::class, fn ($mail) => $mail->hasTo('betroffen@example.test'));
Mail::assertQueued(LegalRequestSubmitted::class);
});
test('a personal rights request requires an email', function () {
/** @var TestCase $this */
$release = publishedRelease();
$this->post(route('legal-request.store', ['slug' => $release->slug]), [
'type' => LegalRequestType::PersonalRights->value,
'message' => 'Diese Meldung verletzt meine Persönlichkeitsrechte.',
])->assertSessionHasErrors('requester_email');
expect(LegalRequest::query()->count())->toBe(0);
});
test('a report can be submitted without an email and feeds the same queue', function () {
/** @var TestCase $this */
Mail::fake();
$release = publishedRelease();
$this->post(route('legal-request.store', ['slug' => $release->slug]), [
'type' => LegalRequestType::Report->value,
'message' => 'Diese Pressemitteilung wirkt wie Spam.',
])->assertRedirect(route('release.detail', ['slug' => $release->slug]));
$request = LegalRequest::query()->firstOrFail();
expect($request->type)->toBe(LegalRequestType::Report);
expect($request->requester_email)->toBeNull();
// Ohne E-Mail keine Eingangsbestätigung — der interne Hinweis geht trotzdem raus.
Mail::assertNotQueued(LegalRequestReceived::class);
Mail::assertQueued(LegalRequestSubmitted::class);
});
test('a filled honeypot field blocks bots without creating a request', function (string $field) {
/** @var TestCase $this */
$release = publishedRelease();
// Soll: still und neutral abnicken — kein Validierungsfehler, der einem
// Bot die Falle verrät, sondern der normale Erfolgs-Redirect.
$this->post(route('legal-request.store', ['slug' => $release->slug]), [
'type' => LegalRequestType::Report->value,
'message' => 'Spam spam spam spam.',
$field => 'http://spam.example',
])
->assertSessionHasNoErrors()
->assertRedirect(route('release.detail', ['slug' => $release->slug]));
expect(LegalRequest::query()->count())->toBe(0);
})->with(['website', 'homepage']);
test('only one open request per release and type, other types still allowed', function () {
/** @var TestCase $this */
$release = publishedRelease();
LegalRequest::factory()
->type(LegalRequestType::Dsgvo)
->create(['press_release_id' => $release->id, 'status' => LegalRequestStatus::Open]);
// Zweite offene DSGVO-Anfrage → keine Dublette.
$this->post(route('legal-request.store', ['slug' => $release->slug]), [
'type' => LegalRequestType::Dsgvo->value,
'requester_email' => 'noch@example.test',
'message' => 'Nochmal dieselbe Anfrage bitte bearbeiten.',
]);
expect(LegalRequest::query()->where('type', LegalRequestType::Dsgvo->value)->count())->toBe(1);
// Anderer Typ bleibt möglich.
$this->post(route('legal-request.store', ['slug' => $release->slug]), [
'type' => LegalRequestType::Report->value,
'message' => 'Andere Art von Anliegen zu dieser PM.',
]);
expect(LegalRequest::query()->where('type', LegalRequestType::Report->value)->count())->toBe(1);
});
test('the admin queue lists open requests', function () {
/** @var TestCase $this */
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
// Kurzer, fixer Titel: die Queue kürzt Titel via Str::limit(…, 60).
$release = PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->create(['title' => 'Compliance-Test PM']);
LegalRequest::factory()
->type(LegalRequestType::PersonalRights)
->create(['press_release_id' => $release->id, 'status' => LegalRequestStatus::Open]);
Volt::actingAs($admin)
->test('admin.legal-requests.index')
->assertOk()
->assertSee('Compliance-Test PM')
->assertSee(LegalRequestType::PersonalRights->label());
});
test('an admin can resolve a request with a note', function () {
/** @var TestCase $this */
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$legalRequest = LegalRequest::factory()
->type(LegalRequestType::Dsgvo)
->create(['status' => LegalRequestStatus::Open]);
Volt::actingAs($admin)
->test('admin.legal-requests.show', ['id' => $legalRequest->id])
->set('adminNote', 'Daten anonymisiert.')
->call('resolve');
$legalRequest->refresh();
expect($legalRequest->status)->toBe(LegalRequestStatus::Resolved);
expect($legalRequest->resolved_by_user_id)->toBe($admin->id);
expect($legalRequest->resolved_at)->not->toBeNull();
expect($legalRequest->admin_note)->toBe('Daten anonymisiert.');
});
test('an admin can reject a request', function () {
/** @var TestCase $this */
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$legalRequest = LegalRequest::factory()
->type(LegalRequestType::Report)
->create(['status' => LegalRequestStatus::Open]);
Volt::actingAs($admin)
->test('admin.legal-requests.show', ['id' => $legalRequest->id])
->call('reject');
expect($legalRequest->fresh()->status)->toBe(LegalRequestStatus::Rejected);
});