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', '

Nachfassen.

') ->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' => '

Hallo zusammen, es gibt Neuigkeiten.

', ]); $html = (new NewsletterCampaignMail($campaign, $user))->render(); expect($html) ->toContain('Hallo Empfang Emil') ->toContain('zusammen') ->not->toContain('