presseportale/tests/Feature/AddressUnsubscribeTest.php
Kevin Adametz 375138679d web: Erstquelle-Absatz in der Veröffentlichungsmail, vierte Transparenzseite „kostenlos veröffentlichen" (Anhang 1: 1d, 2.4, E7)
Meldungen, die über die API eingegangen sind, bekommen in der
Veröffentlichungsmail einen Absatz zum Unterschied Erstquelle/Kopie —
technische Tatsache, ohne Preis und Angebot; direkt eingereichte nicht.

/pressemitteilung-kostenlos-veroeffentlichen beantwortet die Suche nach
„kostenlos" ehrlich: kein kostenloser Weg (E7), was kostenlose Portale
leisten, warum Links aus Presseportalen kaum tragen (auch bei uns
sponsored nofollow), warum Annahme ohne Gegenwert und Prüfung sich
ausschließen, wer am Ende zahlt. Die Liste kostenloser Portale ist in
config/transparency.php vorbereitet und bleibt leer, bis geprüfte
Einträge mit Prüfdatum vorliegen.

Unter-Navigation, Hilfe, Sitemaps, llms.txt und die Modelltabelle der
Kostenseite verlinken die neue Seite. Anhang 1 und Protokoll Teil 6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:22:05 +00:00

136 lines
6.4 KiB
PHP

<?php
use App\Enums\EmailBounceKind;
use App\Enums\EmailNotificationType;
use App\Enums\EmailSuppressionReason;
use App\Enums\Portal;
use App\Mail\MagicLoginLink;
use App\Mail\PressReleasePublished;
use App\Models\Company;
use App\Models\EmailSuppression;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Models\User;
use App\Services\Auth\ContactAccessService;
use App\Services\Mail\EmailSuppressionService;
use App\Services\PressRelease\PressReleaseService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
/**
* Abmeldung für Empfänger ohne Konto (Roadmap 1d.3): Pressekontakt oder
* Firmen-E-Mail einer API-Meldung. Der Zustand liegt in der Sperrliste;
* selbst angeforderte Anmeldelinks gehen weiter durch.
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->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');
});