admin: Newsletter mit Abmelde-Liste, Follow-up-Filtern und gedrosseltem Versand
- Unterseite /admin/newsletter/abmeldungen listet Newsletter-Opt-outs mit Zeitpunkt, Suche und Link zur User-Detailseite - Kampagnen-Filter: Empfänger einer früheren Kampagne ausschließen (Abgleich per User-ID und E-Mail-Snapshot) für Follow-up-Mailings - Login-Filter kann Monats-Scheiben (von–bis) abgrenzen, um große Listen Monat für Monat abzuarbeiten - Versand läuft in Wellen (Standard 50 Mails / 15 Min, per .env justierbar) gegen SMTP-Limits und Blocklisten; Hinweis im Senden-Modal - web: Firmen-Website im Newsroom & Co. als absolute URL verlinkt (Company::websiteUrl), statt relativ auf /newsroom/www.example.de - kunde: API-Doku-Button öffnet die /de/api-Seite; OpenAPI-Spezifikation als eigener, benannter Download (pressekonto-api-v1.yml) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
45dc4ce66c
commit
021ee2d23c
19 changed files with 499 additions and 20 deletions
|
|
@ -17,6 +17,7 @@ use App\Services\Newsletter\CampaignRecipients;
|
|||
use App\Services\Newsletter\CampaignSender;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -80,6 +81,134 @@ test('the recipient filters cover legacy contracts and login windows', function
|
|||
->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,
|
||||
]);
|
||||
|
||||
LivewireVolt::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.
|
||||
LivewireVolt::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.
|
||||
LivewireVolt::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([
|
||||
|
|
@ -131,6 +260,27 @@ test('sending a campaign mails every recipient and freezes the protocol', functi
|
|||
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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue