presseportale/tests/Feature/AddressUnsubscribeTest.php
Kevin Adametz 35db30e019 mail: adressbezogene Abmeldung fuer Empfaenger ohne Konto (Roadmap 1d.3)
Der Abmeldelink in API-Benachrichtigungen zeigte auf das Konto des
Einlieferers - ein Klick des Pressekontakts haette connektar abgemeldet.
Jetzt traegt der Link die Empfaengeradresse (signiert), der Zustand liegt
als Unsubscribed in der Sperrliste. Anmeldelinks, die die Person selbst
anfordert, gehen weiter durch; harte Bounces sperren alles.

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

113 lines
5.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();
});