presseportale/tests/Feature/Admin/NewsletterCampaignTest.php
Kevin Adametz 215cbcbdbf admin: internes Newsletter-Modul mit Filtern, Protokoll und Abmeldung
Neues Modul unter Admin ▸ Newsletter (nur users:manage): Kampagnen mit
Betreff + leicht formatiertem Text (Purifier-Profil newsletter: fett/
kursiv, Listen, Links) im zentralen Mail-Layout. Empfänger-Filter auf
der User-Datenbank: Portal, Legacy-Vertrag (Bestandsschutz), Login
innerhalb/außerhalb X Monaten, Sortierung nach Login/Erstellung — mit
Live-Zähler und Vorschau im Editor.

Versand friert die Empfängerliste als Protokoll ein (newsletter_
campaign_recipients) und stellt pro Empfänger einen Queue-Job ein;
der Opt-out wird im Worker erneut geprüft. Jede Mail trägt den
signierten One-Click-Abmelde-Link (neuer EmailNotificationType::
Newsletter, bestehende Unsubscribe-Infrastruktur inkl. RFC 8058);
die Abmeldung greift sofort für alle künftigen Auswahlen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 09:26:57 +00:00

223 lines
8.7 KiB
PHP

<?php
use App\Enums\EmailNotificationType;
use App\Enums\NewsletterCampaignStatus;
use App\Enums\NewsletterRecipientStatus;
use App\Enums\Portal;
use App\Enums\UserPaymentOptionStatus;
use App\Jobs\SendNewsletterCampaignMail;
use App\Mail\NewsletterCampaignMail;
use App\Models\NewsletterCampaign;
use App\Models\PaymentOption;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Newsletter\CampaignRecipients;
use App\Services\Newsletter\CampaignSender;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
/**
* Internes Newsletter-Modul (Admin ▸ Newsletter): Empfänger-Filter,
* Versand mit Protokoll und die sofort greifende Abmeldung.
*/
function newsletterAdmin(): User
{
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
return $admin;
}
test('the recipient filters cover legacy contracts and login windows', function () {
$recipients = app(CampaignRecipients::class);
$legacyUser = User::factory()->create(['name' => 'Legacy Lisa', 'last_login_at' => now()->subMonths(2)]);
UserPaymentOption::factory()->create([
'user_id' => $legacyUser->id,
'payment_option_id' => PaymentOption::factory()->create()->id,
'status' => UserPaymentOptionStatus::Grandfathered->value,
'grandfathered_until' => null,
'stripe_subscription_id' => null,
]);
$recentUser = User::factory()->create(['name' => 'Recent Rita', 'last_login_at' => now()->subMonth()]);
$dormantUser = User::factory()->create(['name' => 'Dormant Doris', 'last_login_at' => now()->subMonths(18)]);
$neverLoggedIn = User::factory()->create(['name' => 'Nie Nora', 'last_login_at' => null]);
$inactiveUser = User::factory()->create(['is_active' => false, 'last_login_at' => now()->subMonth()]);
$unsubscribed = User::factory()->create(['name' => 'Abgemeldet Anna', 'last_login_at' => now()->subMonth()]);
$unsubscribed->optOutOfEmailNotification(EmailNotificationType::Newsletter);
// Grundmenge: aktiv, mit E-Mail, nicht abgemeldet.
$all = $recipients->query([])->pluck('id');
expect($all)->toContain($legacyUser->id, $recentUser->id, $dormantUser->id, $neverLoggedIn->id)
->not->toContain($inactiveUser->id)
->not->toContain($unsubscribed->id);
// Nur Legacy-Verträge (Bestandsschutz).
expect($recipients->query(['legacy_contract' => true])->pluck('id')->all())
->toBe([$legacyUser->id]);
// In den letzten 6 Monaten eingeloggt.
$within = $recipients->query(['login' => 'within', 'login_months' => 6])->pluck('id');
expect($within)->toContain($legacyUser->id, $recentUser->id)
->not->toContain($dormantUser->id)
->not->toContain($neverLoggedIn->id);
// Komplement: seit 6 Monaten nicht (oder noch nie) eingeloggt.
$notWithin = $recipients->query(['login' => 'not_within', 'login_months' => 6])->pluck('id');
expect($notWithin)->toContain($dormantUser->id, $neverLoggedIn->id)
->not->toContain($recentUser->id);
// Sortierung nach letztem Login, älteste zuerst (NULL zuerst).
$sorted = $recipients->query(['sort' => 'last_login_at', 'direction' => 'asc'])->pluck('id')->all();
expect($sorted[0])->toBe($neverLoggedIn->id)
->and(end($sorted))->toBe($recentUser->id);
});
test('the campaign mail carries the body, salutation and a signed unsubscribe link', function () {
$user = User::factory()->create(['name' => 'Empfang Emil']);
$campaign = NewsletterCampaign::factory()->create([
'subject' => 'Unser Portal ist umgezogen',
'body' => '<p>Hallo <strong>zusammen</strong>,<script>alert(1)</script> es gibt Neuigkeiten.</p>',
]);
$html = (new NewsletterCampaignMail($campaign, $user))->render();
expect($html)
->toContain('Hallo Empfang Emil')
->toContain('<strong>zusammen</strong>')
->not->toContain('<script>')
->toContain('Warum erhalten Sie diese E-Mail?')
->toContain('e-mail-benachrichtigungen/abmelden/'.$user->id.'/'.EmailNotificationType::Newsletter->value)
->toContain('signature=');
});
test('sending a campaign mails every recipient and freezes the protocol', function () {
Mail::fake();
$userA = User::factory()->create();
$userB = User::factory()->create();
$optedOut = User::factory()->create();
$optedOut->optOutOfEmailNotification(EmailNotificationType::Newsletter);
$campaign = NewsletterCampaign::factory()->create();
// Sync-Queue im Test: start() versendet direkt.
$total = app(CampaignSender::class)->start($campaign);
expect($total)->toBe(2);
Mail::assertSent(NewsletterCampaignMail::class, 2);
Mail::assertNotSent(NewsletterCampaignMail::class, fn (NewsletterCampaignMail $mail) => $mail->user->is($optedOut));
$campaign->refresh();
expect($campaign->status)->toBe(NewsletterCampaignStatus::Sent)
->and($campaign->recipients_total)->toBe(2)
->and($campaign->sent_count)->toBe(2)
->and($campaign->failed_count)->toBe(0)
->and($campaign->sent_at)->not->toBeNull();
$emails = $campaign->recipients()->pluck('email');
expect($emails)->toContain($userA->email, $userB->email)
->and($campaign->recipients()->where('status', NewsletterRecipientStatus::Sent->value)->count())->toBe(2);
// Ein zweiter Start ist wirkungslos (kein Doppelversand).
expect(app(CampaignSender::class)->start($campaign))->toBe(0);
});
test('an opt-out between send start and delivery is skipped and recorded', function () {
Mail::fake();
$user = User::factory()->create();
$campaign = NewsletterCampaign::factory()->create([
'status' => NewsletterCampaignStatus::Sending->value,
'recipients_total' => 1,
]);
$recipient = $campaign->recipients()->create([
'user_id' => $user->id,
'email' => $user->email,
'status' => NewsletterRecipientStatus::Pending->value,
]);
// Abmeldung nach Versandstart, bevor der Worker den Job verarbeitet.
$user->optOutOfEmailNotification(EmailNotificationType::Newsletter);
(new SendNewsletterCampaignMail($recipient->id))->handle();
Mail::assertNothingSent();
expect($recipient->refresh()->status)->toBe(NewsletterRecipientStatus::SkippedOptOut);
$campaign->refresh();
expect($campaign->skipped_count)->toBe(1)
->and($campaign->status)->toBe(NewsletterCampaignStatus::Sent);
});
test('the newsletter unsubscribe link records the opt-out immediately', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$url = URL::signedRoute('email-preferences.unsubscribe', [
'user' => $user->id,
'type' => EmailNotificationType::Newsletter->value,
]);
$this->get($url)->assertOk()->assertSee('abgemeldet');
expect($user->fresh()->wantsEmailNotification(EmailNotificationType::Newsletter))->toBeFalse();
// Abgemeldete User fallen sofort aus jeder Empfänger-Auswahl.
expect(app(CampaignRecipients::class)->query([])->pluck('id'))->not->toContain($user->id);
});
test('the campaign editor saves drafts and sends via the queue', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Mail::fake();
$admin = newsletterAdmin();
$this->actingAs($admin);
User::factory()->create(['portal' => Portal::Presseecho, 'last_login_at' => now()->subMonth()]);
LivewireVolt::test('admin.newsletter.campaigns.edit')
->set('subject', 'Willkommen auf der neuen Plattform')
->set('body', '<p>Es gibt <em>Neuigkeiten</em>.</p>')
->call('saveDraft')
->assertHasNoErrors();
$campaign = NewsletterCampaign::query()->sole();
expect($campaign->isDraft())->toBeTrue()
->and($campaign->created_by_user_id)->toBe($admin->id);
LivewireVolt::test('admin.newsletter.campaigns.edit', ['id' => $campaign->id])
->call('send')
->assertHasNoErrors();
$campaign->refresh();
expect($campaign->status)->toBe(NewsletterCampaignStatus::Sent)
->and($campaign->recipients_total)->toBeGreaterThanOrEqual(2); // Empfänger + Admin selbst
Mail::assertSent(NewsletterCampaignMail::class, $campaign->recipients_total);
});
test('the campaign pages require the users manage permission', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$editor = User::factory()->create(['is_active' => true]);
$editor->assignRole('editor');
$this->actingAs($editor);
$this->get(route('admin.newsletter.campaigns.index'))->assertForbidden();
$admin = newsletterAdmin();
$this->actingAs($admin);
$this->get(route('admin.newsletter.campaigns.index'))->assertOk();
});