published()->create(); $contact = Contact::factory()->create(['email' => $contactEmail]); $pressRelease->contacts()->attach($contact->id); return $pressRelease; } function detailUrlFor(PressRelease $pressRelease): string { return 'https://presseecho.test/de/press-release/'.$pressRelease->slug; } test('the intake sends a magic link to the stored press contact, never to a form address', function () { /** @var TestCase $this */ Mail::fake(); $pressRelease = publishedReleaseWithContact('presse@firma.example'); $response = $this->post('/de/melden/pm', [ 'reported_url' => detailUrlFor($pressRelease), ]); $response->assertRedirect(); $response->assertSessionHas('pm-report-status', fn (string $status) => str_contains($status, 'p***@firma.example')); Mail::assertQueued(PressReleaseReportLink::class, fn (PressReleaseReportLink $mail) => $mail->hasTo('presse@firma.example')); $link = MagicLink::query()->where('purpose', 'press_release_report')->sole(); expect($link->payload['press_release_id'])->toBe($pressRelease->id) ->and($link->payload['contact_email'])->toBe('presse@firma.example'); }); test('the intake falls back to the company contact when the release has none', function () { /** @var TestCase $this */ Mail::fake(); // Legacy-Import-Fall: keine PM↔Kontakt-Verknüpfung, aber die Firma hat // einen Pressekontakt — der zählt als verifizierbarer Kanal. $pressRelease = PressRelease::factory()->published()->create(); Contact::factory()->create([ 'company_id' => $pressRelease->company_id, 'email' => 'firmenkontakt@firma.example', ]); expect($pressRelease->contacts()->count())->toBe(0); $this->post('/de/melden/pm', [ 'reported_url' => detailUrlFor($pressRelease), ])->assertRedirect(); Mail::assertQueued(PressReleaseReportLink::class, fn (PressReleaseReportLink $mail) => $mail->hasTo('firmenkontakt@firma.example')); }); test('an unknown url is rejected with a validation error', function () { /** @var TestCase $this */ Mail::fake(); $this->post('/de/melden/pm', ['reported_url' => 'https://presseecho.test/de/press-release/gibt-es-nicht']) ->assertSessionHasErrors('reported_url'); Mail::assertNothingQueued(); }); test('a release without any contact email points to the manual fallback', function () { /** @var TestCase $this */ Mail::fake(); $pressRelease = PressRelease::factory()->published()->create(); $pressRelease->company()->withoutGlobalScopes()->update(['email' => null]); $this->post('/de/melden/pm', ['reported_url' => detailUrlFor($pressRelease)]) ->assertSessionHas('pm-report-manual', true); Mail::assertNothingQueued(); }); test('link requests per release are rate limited', function () { /** @var TestCase $this */ Mail::fake(); config()->set('legal.report_links_per_day', 1); $pressRelease = publishedReleaseWithContact(); $this->post('/de/melden/pm', ['reported_url' => detailUrlFor($pressRelease)]) ->assertSessionHas('pm-report-status'); $this->post('/de/melden/pm', ['reported_url' => detailUrlFor($pressRelease)]) ->assertSessionHasErrors('reported_url'); Mail::assertQueuedCount(1); }); test('the magic link verifies once and opens the concern selection', function () { /** @var TestCase $this */ $pressRelease = publishedReleaseWithContact(); $link = app(MagicLinkGenerator::class)->createReportLink($pressRelease, 'presse@firma.example'); $this->get('/de/melden/pm/verifizieren/'.$link['plain_token']) ->assertRedirect('/de/melden/pm/anliegen'); $this->get('/de/melden/pm/anliegen') ->assertOk() ->assertSee($pressRelease->title) ->assertSee('p***@firma.example'); // Zweiter Klick: Link ist verbraucht. $this->get('/de/melden/pm/verifizieren/'.$link['plain_token']) ->assertRedirect('/de/melden/pm') ->assertSessionHas('pm-report-error'); }); test('a verified visitor can submit a concern which lands verified in the queue', function () { /** @var TestCase $this */ Mail::fake(); $pressRelease = publishedReleaseWithContact(); $link = app(MagicLinkGenerator::class)->createReportLink($pressRelease, 'presse@firma.example'); $this->get('/de/melden/pm/verifizieren/'.$link['plain_token']); $this->post('/de/melden/pm/anliegen', [ 'concern' => 'correction', 'message' => 'Bitte den Tippfehler im Firmennamen korrigieren.', ])->assertSessionHas('pm-report-status'); $request = LegalRequest::query()->sole(); expect($request->type)->toBe(LegalRequestType::Correction) ->and($request->press_release_id)->toBe($pressRelease->id) ->and($request->requester_email)->toBe('presse@firma.example') ->and($request->verified_at)->not->toBeNull() ->and($request->verification_method)->toBe(LegalRequestVerificationMethod::MagicLink) ->and($request->status)->toBe(LegalRequestStatus::Open); Mail::assertQueued(LegalRequestReceived::class, fn (LegalRequestReceived $mail) => $mail->hasTo('presse@firma.example')); }); test('the dsgvo concern requires terms and enforces the mandatory preview', function () { /** @var TestCase $this */ Mail::fake(); $pressRelease = publishedReleaseWithContact(); $pressRelease->forceFill(['title' => 'Max Mustermann übernimmt die Leitung'])->save(); $link = app(MagicLinkGenerator::class)->createReportLink($pressRelease, 'presse@firma.example'); $this->get('/de/melden/pm/verifizieren/'.$link['plain_token']); // Ohne Begriffe und ohne Entfernungs-Option → Fehler. $this->post('/de/melden/pm/anliegen', [ 'concern' => 'dsgvo', 'message' => 'Bitte meinen Namen entfernen, danke.', ])->assertSessionHasErrors('redaction_terms'); // Begriffe ohne Treffer → Fehler mit Hinweis auf die Schreibweise. $this->post('/de/melden/pm/anliegen', [ 'concern' => 'dsgvo', 'message' => 'Bitte meinen Namen entfernen, danke.', 'redaction_terms' => 'Kommtnichtvor', ])->assertSessionHasErrors('redaction_terms'); // Mit Treffern → Pflicht-Vorschau (kein Redirect), noch KEINE Anfrage. $this->post('/de/melden/pm/anliegen', [ 'concern' => 'dsgvo', 'message' => 'Bitte meinen Namen entfernen, danke.', 'redaction_terms' => 'Max Mustermann', ])->assertOk() ->assertSee('Vorschau: So sieht die Mitteilung nach der Schwärzung aus') ->assertSee('[entfernt] übernimmt die Leitung') ->assertSee('Verbindlich einreichen'); expect(LegalRequest::query()->count())->toBe(0) ->and($pressRelease->fresh()->title)->toBe('Max Mustermann übernimmt die Leitung'); }); test('the confirmed dsgvo submission stores a structured redaction order without touching the release', function () { /** @var TestCase $this */ Mail::fake(); $pressRelease = publishedReleaseWithContact(); $pressRelease->forceFill(['title' => 'Max Mustermann übernimmt die Leitung'])->save(); $link = app(MagicLinkGenerator::class)->createReportLink($pressRelease, 'presse@firma.example'); $this->get('/de/melden/pm/verifizieren/'.$link['plain_token']); $this->post('/de/melden/pm/anliegen', [ 'concern' => 'dsgvo', 'message' => 'Bitte meinen Namen entfernen, danke.', 'redaction_terms' => "Max Mustermann\nMustermanns", 'detach_contacts' => '1', 'remove_images' => '1', 'confirmed' => '1', ])->assertSessionHas('pm-report-status'); $request = LegalRequest::query()->sole(); expect($request->type)->toBe(LegalRequestType::Dsgvo) ->and($request->status)->toBe(LegalRequestStatus::Open) ->and($request->payload['redaction']['terms'])->toBe(['Max Mustermann', 'Mustermanns']) ->and($request->payload['redaction']['detach_contacts'])->toBeTrue() ->and($request->payload['redaction']['remove_images_requested'])->toBeTrue() ->and($request->payload['redaction']['preview_total_hits'])->toBeGreaterThan(0) ->and($request->executed_at)->toBeNull() // Einreichung ≠ Ausführung: an der PM ändert sich nichts. ->and($pressRelease->fresh()->title)->toBe('Max Mustermann übernimmt die Leitung') ->and($pressRelease->fresh()->contacts()->count())->toBe(1); Mail::assertQueued(LegalRequestReceived::class, fn (LegalRequestReceived $mail) => $mail->hasTo('presse@firma.example')); }); test('a second open concern of the same type is blocked', function () { /** @var TestCase $this */ Mail::fake(); $pressRelease = publishedReleaseWithContact(); $link = app(MagicLinkGenerator::class)->createReportLink($pressRelease, 'presse@firma.example'); $this->get('/de/melden/pm/verifizieren/'.$link['plain_token']); LegalRequest::factory()->type(LegalRequestType::Correction)->create([ 'press_release_id' => $pressRelease->id, 'status' => LegalRequestStatus::Open, ]); $this->post('/de/melden/pm/anliegen', [ 'concern' => 'correction', 'message' => 'Bitte Tippfehler im Titel korrigieren.', ])->assertSessionHasErrors('concern'); }); test('the concern page requires a verified session', function () { /** @var TestCase $this */ $this->get('/de/melden/pm/anliegen') ->assertRedirect('/de/melden/pm') ->assertSessionHas('pm-report-error'); }); test('a logged-in owner skips verification and lands in the concern selection', function () { /** @var TestCase $this */ $this->seed(RolesAndPermissionsSeeder::class); $owner = User::factory()->create(['is_active' => true]); $owner->assignRole('customer'); $pressRelease = PressRelease::factory()->published()->create(['user_id' => $owner->id]); $this->actingAs($owner) ->post('/admin/me/press-releases/'.$pressRelease->id.'/melden') ->assertRedirect('/de/melden/pm/anliegen'); $this->get('/de/melden/pm/anliegen')->assertOk()->assertSee($pressRelease->title); }); test('a foreign user cannot start the report flow for someone elses release', function () { /** @var TestCase $this */ $this->seed(RolesAndPermissionsSeeder::class); $stranger = User::factory()->create(['is_active' => true]); $stranger->assignRole('customer'); $pressRelease = PressRelease::factory()->published()->create(); $this->actingAs($stranger) ->post('/admin/me/press-releases/'.$pressRelease->id.'/melden') ->assertForbidden(); });