seed(RolesAndPermissionsSeeder::class); Mail::fake(); }); function apiReleaseFor(string $companyEmail): PressRelease { $submitter = User::factory()->create(['is_active' => true, 'email' => 'api@connektar.example']); $company = Company::factory()->create(['portal' => Portal::Businessportal24->value, 'email' => $companyEmail, 'name' => 'Muster GmbH']); $release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['user_id' => $submitter->id, 'company_id' => $company->id]); PressReleaseStatusLog::query()->create(['press_release_id' => $release->id, 'from_status' => 'draft', 'to_status' => 'review', 'source' => 'api']); return $release; } test('the notification to a recipient without an account carries an address-based unsubscribe link', function () { /** @var TestCase $this */ $release = apiReleaseFor('presse@muster.example'); $mail = new PressReleasePublished($release->user, $release, 'Muster GmbH', 'presse@muster.example'); // Vorher zeigte der Link auf das Konto des Einlieferers — ein Klick des // Pressekontakts hätte connektar abgemeldet. expect($mail->unsubscribeUrl)->toContain('/abmelden-adresse?') ->and($mail->unsubscribeUrl)->toContain('email=presse%40muster.example') ->and($mail->unsubscribeUrl)->not->toContain('/abmelden/'.$release->user_id.'/'); // Der Einlieferer selbst behält den Konto-Link. $own = new PressReleasePublished($release->user, $release, null, $release->user->email); expect($own->unsubscribeUrl)->toContain('/abmelden/'.$release->user_id.'/'); }); test('clicking the address link records an unsubscribe suppression and blocks further notifications', function () { /** @var TestCase $this */ $release = apiReleaseFor('presse@muster.example'); $url = URL::signedRoute('email-preferences.unsubscribe-address', ['email' => 'presse@muster.example', 'type' => EmailNotificationType::PressReleasePublished->value]); $this->get($url)->assertOk()->assertSeeText('gehen keine Benachrichtigungen mehr')->assertSeeText('p***@muster.example'); $suppression = EmailSuppression::query()->where('email', 'presse@muster.example')->firstOrFail(); expect($suppression->reason)->toBe(EmailSuppressionReason::Unsubscribed) ->and($suppression->isActive())->toBeTrue(); // Nächste Benachrichtigung an diese Firma: keine Mail. app(PressReleaseService::class)->reject(tap($release)->update(['status' => 'review']), 'Grund'); Mail::assertNothingQueued(); }); test('an unsubscribed address still receives the login link it requests itself', function () { /** @var TestCase $this */ apiReleaseFor('presse@muster.example'); app(EmailSuppressionService::class)->unsubscribe('presse@muster.example'); Mail::fake(); app(ContactAccessService::class)->requestMagicAccess('presse@muster.example'); Mail::assertQueued(MagicLoginLink::class); // …ein harter Bounce sperrt dagegen alles. app(EmailSuppressionService::class)->recordBounce('presse@muster.example', EmailBounceKind::Hard, 'imap', 'm-1'); expect(app(EmailSuppressionService::class)->isSuppressed('presse@muster.example', includeUnsubscribed: false))->toBeTrue(); }); test('the unsubscribe can be undone from the confirmation page, a tampered link cannot unsubscribe', function () { /** @var TestCase $this */ $suppressions = app(EmailSuppressionService::class); $suppressions->unsubscribe('presse@muster.example'); $this->post(URL::signedRoute('email-preferences.resubscribe-address', ['email' => 'presse@muster.example', 'type' => 'press_release_published'])) ->assertOk()->assertSeeText('wieder Benachrichtigungen'); expect($suppressions->isSuppressed('presse@muster.example'))->toBeFalse(); $this->get(route('email-preferences.unsubscribe-address', ['email' => 'opfer@example.org', 'type' => 'press_release_published', 'signature' => 'x'])) ->assertOk()->assertSeeText('Link ungültig'); expect($suppressions->isSuppressed('opfer@example.org'))->toBeFalse(); }); test('a hard-bounce suppression is not downgraded to an unsubscribe', function () { /** @var TestCase $this */ $suppressions = app(EmailSuppressionService::class); $suppressions->recordBounce('tot@muster.example', EmailBounceKind::Hard, 'imap', 'm-2'); $suppressions->unsubscribe('tot@muster.example'); expect(EmailSuppression::query()->where('email', 'tot@muster.example')->first()->reason)->toBe(EmailSuppressionReason::HardBounce) ->and($suppressions->resubscribe('tot@muster.example'))->toBeFalse(); }); test('the published mail explains original source versus copy only for syndicated releases', function () { /** @var TestCase $this */ // Roadmap 1d (Anhang 17.09.2026): technische Tatsache, kein Angebot — // und nur dort, wo sie zutrifft. $release = apiReleaseFor('presse@muster.example'); app(PressReleaseService::class)->publish(tap($release)->update(['status' => 'review']), 'admin'); Mail::assertQueued(PressReleasePublished::class, function (PressReleasePublished $mail): bool { $html = $mail->render(); return $mail->viaSyndication && str_contains($html, 'über einen Verteiler bei uns eingegangen') && str_contains($html, 'als Erstquelle') && ! str_contains($html, '€'); }); $direct = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(); $directHtml = (new PressReleasePublished($direct->user, $direct))->render(); expect($directHtml)->not->toContain('über einen Verteiler bei uns eingegangen'); });