presseportale/tests/Feature/Admin/NewsletterCampaignTest.php
Kevin Adametz 2cde7284a1 livewire: Volt entfernt — Komponenten als Livewire-4-Single-File-Components, Route::livewire(), Livewire::test(); livewire/volt deinstalliert
- 83 Komponenten: Livewire\Volt\Component → Livewire\Component
- 73 Routen (admin, auth, customer): Volt::route() → Route::livewire()
- 88 Testdateien: Volt::test/actingAs → Livewire::test/actingAs
- VoltServiceProvider gelöscht; composer remove livewire/volt
- config/livewire.php auf v4-Schlüssel (component_layout, component_placeholder,
  component_locations, make_command: SFC ohne ⚡). Der alte Schlüssel layout war
  wirkungslos; alle gerouteten Komponenten setzen ihr Layout selbst.
- CLAUDE.md, AGENTS.md, boost.json, .cursorrules, Skill volt-development
- Roadmap: erledigt; Larastan erfasst SFCs weiterhin nicht (Blade-Dateien)

Suite 2.325 grün, einer übersprungen; Larastan und Pint ohne Meldung;
route:cache und view:cache laufen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:59:36 +00:00

437 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\ActivityType;
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\ActivityEvent;
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 App\Services\Newsletter\NewsletterSendingDisabledException;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\URL;
use Livewire\Livewire;
use Tests\TestCase;
// Versand ist standardmäßig gesperrt (E9-Schalter, Umsetzungsaudit
// 29.09.2026); diese Tests prüfen den Versand selbst.
beforeEach(function (): void {
config()->set('newsletter.sending_enabled', true);
});
/**
* 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 login range filter slices the window month by month', function () {
$recipients = app(CampaignRecipients::class);
$recent = User::factory()->create(['name' => 'Frisch Frida', 'last_login_at' => now()->subMonths(2)->addWeek()]);
$middle = User::factory()->create(['name' => 'Mittel Mia', 'last_login_at' => now()->subMonths(8)->addWeek()]);
$old = User::factory()->create(['name' => 'Alt Ada', 'last_login_at' => now()->subMonths(18)]);
$never = User::factory()->create(['name' => 'Nie Nora', 'last_login_at' => null]);
$slice = fn (int $from, int $to) => $recipients->query([
'login' => CampaignRecipients::LOGIN_BETWEEN,
'login_months_from' => $from,
'login_months' => $to,
])->pluck('id');
// Ganze Spanne 0–12 entspricht "in den letzten 12 Monaten".
expect($slice(0, 12))->toContain($recent->id, $middle->id)
->not->toContain($old->id)
->not->toContain($never->id);
// Einzelne Monats-Scheiben treffen genau ihre Kohorte …
expect($slice(1, 2))->toContain($recent->id)->not->toContain($middle->id);
expect($slice(7, 8))->toContain($middle->id)->not->toContain($recent->id);
// … und die Scheibe dazwischen keinen von beiden.
expect($slice(2, 7))->not->toContain($recent->id)->not->toContain($middle->id);
// Vertauschte/gleiche Grenzen werden auf eine gültige Scheibe normalisiert.
expect($recipients->normalize(['login_months_from' => 12, 'login_months' => 12]))
->toMatchArray(['login_months_from' => 11, 'login_months' => 12]);
expect(implode(' ', $recipients->describe([
'login' => CampaignRecipients::LOGIN_BETWEEN,
'login_months_from' => 11,
'login_months' => 12,
])))->toContain('Letzter Login vor 11 bis 12 Monaten');
});
test('a campaign can exclude the recipients of an earlier campaign', function () {
$recipients = app(CampaignRecipients::class);
$alreadyMailed = User::factory()->create(['name' => 'Bereits Berta']);
$renamedEmail = User::factory()->create(['name' => 'Snapshot Sven']);
$notYetMailed = User::factory()->create(['name' => 'Neu Nina']);
$earlier = NewsletterCampaign::factory()->create([
'subject' => 'Legacy-Info Runde 1',
'status' => NewsletterCampaignStatus::Sent->value,
]);
$earlier->recipients()->create([
'user_id' => $alreadyMailed->id,
'email' => $alreadyMailed->email,
'status' => NewsletterRecipientStatus::Sent->value,
]);
// Protokoll-Zeile ohne User-Verknüpfung (z. B. gelöschter Account):
// der E-Mail-Snapshot greift trotzdem.
$earlier->recipients()->create([
'user_id' => null,
'email' => $renamedEmail->email,
'status' => NewsletterRecipientStatus::Sent->value,
]);
$ids = $recipients->query(['exclude_campaign_id' => $earlier->id])->pluck('id');
expect($ids)->toContain($notYetMailed->id)
->not->toContain($alreadyMailed->id)
->not->toContain($renamedEmail->id);
// Ohne Ausschluss bleiben alle drin.
expect($recipients->query([])->pluck('id'))
->toContain($alreadyMailed->id, $renamedEmail->id, $notYetMailed->id);
// Die Zusammenfassung benennt die ausgeschlossene Kampagne.
expect(implode(' ', $recipients->describe(['exclude_campaign_id' => $earlier->id])))
->toContain('Legacy-Info Runde 1');
});
test('the campaign editor stores the exclusion filter in the snapshot', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$this->actingAs(newsletterAdmin());
$earlier = NewsletterCampaign::factory()->create([
'status' => NewsletterCampaignStatus::Sent->value,
]);
Livewire::test('admin.newsletter.campaigns.edit')
->set('subject', 'Follow-up an die noch nicht Erreichten')
->set('body', '<p>Nachfassen.</p>')
->set('filterExcludeCampaign', (string) $earlier->id)
->call('saveDraft')
->assertHasNoErrors();
$draft = NewsletterCampaign::query()->whereKeyNot($earlier->id)->sole();
expect($draft->filters['exclude_campaign_id'])->toBe($earlier->id);
// Beim erneuten Öffnen ist die Auswahl vorbelegt.
Livewire::test('admin.newsletter.campaigns.edit', ['id' => $draft->id])
->assertSet('filterExcludeCampaign', (string) $earlier->id);
});
test('the unsubscribes page lists newsletter opt-outs with their date', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$optedOut = User::factory()->create(['name' => 'Abgemeldet Anna']);
$optedOut->optOutOfEmailNotification(EmailNotificationType::Newsletter);
$subscribed = User::factory()->create(['name' => 'Dabei Doris']);
$editor = User::factory()->create(['is_active' => true]);
$editor->assignRole('editor');
$this->actingAs($editor);
$this->get(route('admin.newsletter.unsubscribes'))->assertForbidden();
$this->actingAs(newsletterAdmin());
$this->get(route('admin.newsletter.unsubscribes'))
->assertOk()
->assertSee('Abgemeldet Anna')
->assertDontSee('Dabei Doris');
// Suche grenzt die Liste ein.
Livewire::test('admin.newsletter.unsubscribes')
->assertSee('Abgemeldet Anna')
->set('search', $optedOut->email)
->assertSee('Abgemeldet Anna')
->set('search', 'trifft-nichts')
->assertDontSee('Abgemeldet Anna');
});
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('campaign sending is throttled into timed waves', function () {
Queue::fake();
config(['newsletter.campaign.batch_size' => 2, 'newsletter.campaign.batch_interval_minutes' => 15]);
User::factory()->count(5)->create();
$campaign = NewsletterCampaign::factory()->create();
expect(app(CampaignSender::class)->start($campaign))->toBe(5);
// Wellen à 2 Mails im 15-Minuten-Abstand: 0s, 0s, 900s, 900s, 1800s.
$delays = collect(Queue::pushedJobs()[SendNewsletterCampaignMail::class])
->map(fn (array $pushed) => $pushed['job']->delay)
->all();
expect($delays)->toBe([0, 0, 900, 900, 1800]);
// UI-Hinweis: erst ab der zweiten Welle nötig.
expect(CampaignSender::throttleSummary(2))->toBeNull()
->and(CampaignSender::throttleSummary(5))->toContain('max. 2 Mails alle 15 Minuten', '30 Minuten');
});
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);
// Die Abmeldung steht im Aktivitäts-Verlauf — ein zweiter Klick
// erzeugt keinen Doppel-Eintrag.
$this->get($url)->assertOk();
$event = ActivityEvent::query()
->where('type', ActivityType::EmailUnsubscribe->value)
->sole();
expect($event->user_id)->toBe($user->id)
->and($event->description)->toContain('abbestellt')
->and($event->meta['method'])->toBe('link')
->and($event->meta['type'])->toBe('newsletter');
});
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()]);
Livewire::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);
Livewire::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();
});
/*
* E9-Schalter (Umsetzungsaudit 29.09.2026): Bis U05–U07 umgesetzt sind,
* startet keine Kampagne — weder im Service noch in der Admin-UI noch per
* Wiederanlauf. Abmeldung und Sperrliste laufen unabhängig davon.
*/
test('with sending disabled a campaign cannot be started anywhere', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
config()->set('newsletter.sending_enabled', false);
Queue::fake();
$admin = newsletterAdmin();
$this->actingAs($admin);
User::factory()->create(['portal' => Portal::Presseecho, 'last_login_at' => now()->subMonth()]);
$campaign = NewsletterCampaign::factory()->create();
expect(fn () => app(CampaignSender::class)->start($campaign))->toThrow(NewsletterSendingDisabledException::class);
Livewire::test('admin.newsletter.campaigns.edit', ['id' => $campaign->id])
->assertSee('Der Newsletter-Versand ist derzeit gesperrt')
->call('send')
->assertNoRedirect();
expect($campaign->fresh()->isDraft())->toBeTrue()
->and($campaign->recipients()->count())->toBe(0);
Queue::assertNothingPushed();
});
test('with sending disabled an unsubscribe still works', function () {
/** @var TestCase $this */
config()->set('newsletter.sending_enabled', false);
$user = User::factory()->create();
$this->get(URL::signedRoute('email-preferences.unsubscribe', [
'user' => $user->id,
'type' => EmailNotificationType::Newsletter->value,
]))->assertOk()->assertSee('abgemeldet');
expect($user->fresh()->wantsEmailNotification(EmailNotificationType::Newsletter))->toBeFalse();
});