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:
Kevin Adametz 2026-07-22 12:38:47 +00:00
parent 45dc4ce66c
commit 021ee2d23c
19 changed files with 499 additions and 20 deletions

View file

@ -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();

View file

@ -7,6 +7,8 @@ test('api v1 documentation is publicly available', function () {
$this->get('/docs/api/v1')
->assertOk()
->assertHeader('content-type', 'application/yaml; charset=UTF-8')
// Benannter Download statt anonymem „v1.yml“ im Download-Ordner.
->assertDownload('pressekonto-api-v1.yml')
->assertSee('openapi: 3.1.0', false)
->assertSee('/press-releases:', false)
// Übernahme der Legacy-Integrationen (06.07.): Alt-Schlüssel bleiben

View file

@ -43,6 +43,21 @@ test('customer can create and revoke api tokens with selected abilities', functi
expect($customer->tokens()->count())->toBe(0);
});
test('the tokens page links the api docs page and the named spec download', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$this->actingAs($customer);
// Doku-Button öffnet die API-Seite; der Spec-Download ist ein eigener,
// klar benannter Button (kein Sofort-Download hinter „Dokumentation“).
LivewireVolt::test('customer.tokens')
->assertSee('API-Dokumentation')
->assertSee(route('api', ['edition' => 'de']))
->assertSee('OpenAPI-Spezifikation (YAML)')
->assertSee(route('docs.api.v1'));
});
test('customer cannot create api token without active payment access', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);

View file

@ -84,6 +84,26 @@ test('the newsroom detail page renders company data and its releases', function
->assertSeeText('Alle Meldungen');
});
test('the newsroom detail page links the company website with an absolute url', function () {
/** @var TestCase $this */
// Bestandsdaten ohne Schema („www.akgsoftware.de“) — als roher href
// würde daraus der relative Link /de/newsroom/www.akgsoftware.de.
$company = makeNewsroom('AKG Software Consulting GmbH', attributes: [
'website' => 'www.akgsoftware.de',
]);
publishForCompany($company, 'AKG veröffentlicht neue Version');
$this->get('https://businessportal24.test/de/newsroom/'.$company->slug)
->assertSuccessful()
->assertSee('href="https://www.akgsoftware.de"', false)
->assertDontSee('newsroom/www.akgsoftware.de', false);
// Hinterlegte URLs mit Schema bleiben unverändert.
expect(Company::factory()->make(['website' => 'https://akgsoftware.de'])->websiteUrl())
->toBe('https://akgsoftware.de')
->and(Company::factory()->make(['website' => null])->websiteUrl())->toBeNull();
});
test('the newsroom detail page returns 404 for an unknown slug', function () {
/** @var TestCase $this */
$this->get('https://businessportal24.test/de/newsroom/gibt-es-nicht')