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>
223 lines
8.7 KiB
PHP
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();
|
|
});
|