- 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>
437 lines
17 KiB
PHP
437 lines
17 KiB
PHP
<?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();
|
||
});
|