- K-01: publish-scheduled filtert manual_review_required_at; publish() prüft für ki/scheduler unter der Sperre (ManualReviewRequiredException) - K-02: Kontingent- und Bildsperre sind fachliche Ausgänge (Exit 0, einmaliger Verlaufseintrag); KI-Job ohne Retry bei Bildsperre - O06: Kandidaten per Cursor (scheduled_at, id), Limit zählt bearbeitete Meldungen, Blockaden nur ein Scan-Budget - K-05: KI-Job lädt vor dem Routing neu, überholter Status ohne Retry; backoff - K-06: Admin-Aktionen mit Permission-Prüfung und Fehlermeldung statt 500 - K-09: withoutOverlapping() mit kurzer Sperrdauer, purge-events 03:50, neuer Wochenlauf press-release-images:sweep-orphan-originals (So 04:45) - E9: NEWSLETTER_SENDING_ENABLED (Standard aus) sperrt Start, Resume und Job - OPT-9: ExpireGrandfathering prüft die Queue-Voraussetzung aus E18 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
410 lines
17 KiB
PHP
410 lines
17 KiB
PHP
<?php
|
|
|
|
use App\Enums\EmailSuppressionReason;
|
|
use App\Enums\NewsletterCampaignStatus;
|
|
use App\Enums\NewsletterRecipientStatus;
|
|
use App\Jobs\SendNewsletterCampaignMail;
|
|
use App\Mail\NewsletterCampaignMail;
|
|
use App\Models\NewsletterCampaign;
|
|
use App\Models\NewsletterCampaignRecipient;
|
|
use App\Models\User;
|
|
use App\Services\Mail\EmailSuppressionService;
|
|
use App\Services\Newsletter\CampaignSender;
|
|
use Illuminate\Database\UniqueConstraintViolationException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Facades\Queue;
|
|
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);
|
|
});
|
|
|
|
/**
|
|
* Newsletter-Versand unter Last und im Fehlerfall (Audit F05/F14/F15):
|
|
* Chunking über die 500er-Grenze, atomarer Kampagnenstart, eindeutige
|
|
* Empfänger, atomarer Versand-Claim, Sperrliste und Wiederanlauf.
|
|
*/
|
|
|
|
/**
|
|
* Legt $count aktive User an, deren created_at NICHT mit der ID-Reihenfolge
|
|
* korreliert — genau die Konstellation, in der chunkById mit einer
|
|
* führenden created_at-Sortierung Seiten doppelt liest und andere auslässt.
|
|
*
|
|
* @return list<int> die angelegten User-IDs
|
|
*/
|
|
function shuffledNewsletterUsers(int $count): array
|
|
{
|
|
$base = now()->subYears(2);
|
|
|
|
// 7919 ist prim und teilerfremd zu $count → eine Permutation von 0..$count-1.
|
|
$users = User::factory()
|
|
->count($count)
|
|
->sequence(fn ($sequence) => ['created_at' => $base->copy()->addMinutes(($sequence->index * 7919) % $count)])
|
|
->create();
|
|
|
|
return $users->pluck('id')->all();
|
|
}
|
|
|
|
function sendingCampaignWithRecipient(User $user): array
|
|
{
|
|
$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,
|
|
]);
|
|
|
|
return [$campaign, $recipient];
|
|
}
|
|
|
|
test('chunking across the 500 boundary reaches every eligible user exactly once', function () {
|
|
Queue::fake();
|
|
|
|
$ids = shuffledNewsletterUsers(1001);
|
|
|
|
// Default-Filter: Sortierung created_at desc — die UI-Sortierung der
|
|
// Vorschau, nicht die Versandreihenfolge (E4).
|
|
$campaign = NewsletterCampaign::factory()->create(['filters' => []]);
|
|
|
|
$total = app(CampaignSender::class)->start($campaign);
|
|
|
|
expect($total)->toBe(1001);
|
|
|
|
$recipientUserIds = $campaign->recipients()->pluck('user_id')->all();
|
|
|
|
sort($ids);
|
|
sort($recipientUserIds);
|
|
|
|
expect($recipientUserIds)->toBe($ids)
|
|
->and($campaign->recipients()->count())->toBe(1001)
|
|
->and($campaign->refresh()->recipients_total)->toBe(1001);
|
|
|
|
Queue::assertPushed(SendNewsletterCampaignMail::class, 1001);
|
|
});
|
|
|
|
test('the unique index migration removes existing duplicates and keeps the oldest row', function () {
|
|
/** @var TestCase $this */
|
|
$migration = 'database/migrations/2026_09_18_110000_add_unique_user_to_newsletter_campaign_recipients_table.php';
|
|
|
|
$this->artisan('migrate:rollback', ['--path' => $migration])->assertSuccessful();
|
|
|
|
$user = User::factory()->create();
|
|
$campaign = NewsletterCampaign::factory()->create();
|
|
|
|
$kept = $campaign->recipients()->create(['user_id' => $user->id, 'email' => $user->email, 'status' => NewsletterRecipientStatus::Sent->value]);
|
|
$campaign->recipients()->create(['user_id' => $user->id, 'email' => $user->email]);
|
|
$campaign->recipients()->create(['user_id' => $user->id, 'email' => $user->email]);
|
|
// Zeilen ohne User bleiben unangetastet.
|
|
$campaign->recipients()->create(['user_id' => null, 'email' => 'a@example.test']);
|
|
$campaign->recipients()->create(['user_id' => null, 'email' => 'b@example.test']);
|
|
|
|
$this->artisan('migrate', ['--path' => $migration])->assertSuccessful();
|
|
|
|
expect($campaign->recipients()->where('user_id', $user->id)->pluck('id')->all())->toBe([$kept->id])
|
|
->and($campaign->recipients()->whereNull('user_id')->count())->toBe(2);
|
|
});
|
|
|
|
test('the unique index migration keeps the delivered row of a duplicated recipient (Audit U08)', function () {
|
|
/** @var TestCase $this */
|
|
$migration = 'database/migrations/2026_09_18_110000_add_unique_user_to_newsletter_campaign_recipients_table.php';
|
|
|
|
$this->artisan('migrate:rollback', ['--path' => $migration])->assertSuccessful();
|
|
|
|
$campaign = NewsletterCampaign::factory()->create();
|
|
|
|
// Pending → Sent: die ältere Zeile ist offen, die jüngere trägt den Nachweis.
|
|
$pendingThenSent = User::factory()->create();
|
|
$campaign->recipients()->create(['user_id' => $pendingThenSent->id, 'email' => $pendingThenSent->email]);
|
|
$keptA = $campaign->recipients()->create(['user_id' => $pendingThenSent->id, 'email' => $pendingThenSent->email, 'status' => NewsletterRecipientStatus::Sent->value, 'sent_at' => now()]);
|
|
|
|
// Failed → Sent
|
|
$failedThenSent = User::factory()->create();
|
|
$campaign->recipients()->create(['user_id' => $failedThenSent->id, 'email' => $failedThenSent->email, 'status' => NewsletterRecipientStatus::Failed->value]);
|
|
$keptB = $campaign->recipients()->create(['user_id' => $failedThenSent->id, 'email' => $failedThenSent->email, 'status' => NewsletterRecipientStatus::Sent->value, 'sent_at' => now()]);
|
|
|
|
// Sent → Sent: der ältere Nachweis bleibt.
|
|
$sentTwice = User::factory()->create();
|
|
$keptC = $campaign->recipients()->create(['user_id' => $sentTwice->id, 'email' => $sentTwice->email, 'status' => NewsletterRecipientStatus::Sent->value, 'sent_at' => now()]);
|
|
$campaign->recipients()->create(['user_id' => $sentTwice->id, 'email' => $sentTwice->email, 'status' => NewsletterRecipientStatus::Sent->value, 'sent_at' => now()]);
|
|
|
|
$this->artisan('migrate', ['--path' => $migration])->assertSuccessful();
|
|
|
|
expect($campaign->recipients()->orderBy('id')->pluck('id')->all())->toBe([$keptA->id, $keptB->id, $keptC->id])
|
|
->and($campaign->recipients()->where('status', NewsletterRecipientStatus::Sent->value)->count())->toBe(3);
|
|
});
|
|
|
|
test('a campaign cannot hold the same user twice', function () {
|
|
$user = User::factory()->create();
|
|
$campaign = NewsletterCampaign::factory()->create();
|
|
|
|
$campaign->recipients()->create(['user_id' => $user->id, 'email' => $user->email]);
|
|
|
|
expect(fn () => $campaign->recipients()->create(['user_id' => $user->id, 'email' => $user->email]))
|
|
->toThrow(UniqueConstraintViolationException::class);
|
|
|
|
// Protokoll-Zeilen ohne User (gelöschte Accounts) bleiben mehrfach möglich.
|
|
$campaign->recipients()->create(['user_id' => null, 'email' => 'a@example.test']);
|
|
$campaign->recipients()->create(['user_id' => null, 'email' => 'b@example.test']);
|
|
|
|
expect($campaign->recipients()->count())->toBe(3);
|
|
});
|
|
|
|
test('two concurrent starts of the same draft schedule the campaign only once', function () {
|
|
Queue::fake();
|
|
|
|
User::factory()->count(3)->create();
|
|
$campaign = NewsletterCampaign::factory()->create();
|
|
|
|
$first = NewsletterCampaign::query()->findOrFail($campaign->id);
|
|
$second = NewsletterCampaign::query()->findOrFail($campaign->id);
|
|
|
|
$results = [
|
|
app(CampaignSender::class)->start($first),
|
|
app(CampaignSender::class)->start($second),
|
|
];
|
|
|
|
sort($results);
|
|
|
|
expect($results)->toBe([0, 3])
|
|
->and($campaign->recipients()->count())->toBe(3)
|
|
->and($campaign->recipients()->distinct()->count('user_id'))->toBe(3)
|
|
->and($campaign->refresh()->status)->toBe(NewsletterCampaignStatus::Sending)
|
|
->and($campaign->recipients_total)->toBe(3);
|
|
|
|
Queue::assertPushed(SendNewsletterCampaignMail::class, 3);
|
|
});
|
|
|
|
test('a start after an aborted start keeps existing recipients and schedules every pending one', function () {
|
|
Queue::fake();
|
|
|
|
$users = User::factory()->count(3)->create();
|
|
$campaign = NewsletterCampaign::factory()->create();
|
|
|
|
// Rest eines abgebrochenen Starts: die Kampagne ist noch Entwurf, ein
|
|
// Empfänger steht aber schon im Protokoll.
|
|
$campaign->recipients()->create([
|
|
'user_id' => $users[0]->id,
|
|
'email' => $users[0]->email,
|
|
'status' => NewsletterRecipientStatus::Pending->value,
|
|
]);
|
|
|
|
expect(app(CampaignSender::class)->start($campaign))->toBe(3)
|
|
->and($campaign->recipients()->count())->toBe(3)
|
|
->and($campaign->recipients()->where('user_id', $users[0]->id)->count())->toBe(1);
|
|
|
|
Queue::assertPushed(SendNewsletterCampaignMail::class, 3);
|
|
});
|
|
|
|
test('a start without matching recipients leaves the campaign as a draft', function () {
|
|
Queue::fake();
|
|
|
|
$campaign = NewsletterCampaign::factory()->create();
|
|
|
|
expect(app(CampaignSender::class)->start($campaign))->toBe(0)
|
|
->and($campaign->refresh()->status)->toBe(NewsletterCampaignStatus::Draft);
|
|
|
|
Queue::assertNothingPushed();
|
|
});
|
|
|
|
test('the send job retries with backoff', function () {
|
|
$job = new SendNewsletterCampaignMail(1);
|
|
|
|
expect($job->tries)->toBe(3)
|
|
->and($job->backoff())->toBe([30, 120]);
|
|
});
|
|
|
|
test('running the send job twice for one recipient sends exactly one mail', function () {
|
|
Mail::fake();
|
|
|
|
$user = User::factory()->create();
|
|
[$campaign, $recipient] = sendingCampaignWithRecipient($user);
|
|
|
|
(new SendNewsletterCampaignMail($recipient->id))->handle();
|
|
(new SendNewsletterCampaignMail($recipient->id))->handle();
|
|
|
|
Mail::assertSent(NewsletterCampaignMail::class, 1);
|
|
|
|
expect($recipient->refresh()->status)->toBe(NewsletterRecipientStatus::Sent)
|
|
->and($recipient->sent_at)->not->toBeNull();
|
|
|
|
$campaign->refresh();
|
|
expect($campaign->sent_count)->toBe(1)
|
|
->and($campaign->status)->toBe(NewsletterCampaignStatus::Sent);
|
|
});
|
|
|
|
test('a recipient already claimed by another worker is not sent again', function () {
|
|
Mail::fake();
|
|
|
|
$user = User::factory()->create();
|
|
[$campaign, $recipient] = sendingCampaignWithRecipient($user);
|
|
|
|
// Claim eines parallel laufenden Workers.
|
|
DB::table('newsletter_campaign_recipients')
|
|
->where('id', $recipient->id)
|
|
->update(['status' => NewsletterRecipientStatus::Sending->value]);
|
|
|
|
(new SendNewsletterCampaignMail($recipient->id))->handle();
|
|
|
|
Mail::assertNothingSent();
|
|
|
|
expect($recipient->refresh()->status)->toBe(NewsletterRecipientStatus::Sending)
|
|
->and($campaign->refresh()->sent_count)->toBe(0)
|
|
->and($campaign->status)->toBe(NewsletterCampaignStatus::Sending);
|
|
});
|
|
|
|
test('a transport failure releases the claim so the retry can send', function () {
|
|
$user = User::factory()->create();
|
|
[$campaign, $recipient] = sendingCampaignWithRecipient($user);
|
|
|
|
Mail::shouldReceive('to')->once()->andThrow(new RuntimeException('SMTP weg'));
|
|
|
|
expect(fn () => (new SendNewsletterCampaignMail($recipient->id))->handle())
|
|
->toThrow(RuntimeException::class, 'SMTP weg');
|
|
|
|
expect($recipient->refresh()->status)->toBe(NewsletterRecipientStatus::Pending)
|
|
->and($campaign->refresh()->sent_count)->toBe(0)
|
|
->and($campaign->failed_count)->toBe(0);
|
|
|
|
// Nach dem letzten Versuch wird der Empfänger als fehlgeschlagen protokolliert.
|
|
(new SendNewsletterCampaignMail($recipient->id))->failed(new RuntimeException('SMTP weg'));
|
|
|
|
expect($recipient->refresh()->status)->toBe(NewsletterRecipientStatus::Failed)
|
|
->and($recipient->error)->toBe('SMTP weg')
|
|
->and($campaign->refresh()->failed_count)->toBe(1)
|
|
->and($campaign->status)->toBe(NewsletterCampaignStatus::Sent);
|
|
});
|
|
|
|
test('a suppressed address ends as suppressed, not as sent', function () {
|
|
// Bewusst ohne Mail::fake(): das Sperrlisten-Gate hängt am
|
|
// MessageSending-Event, das die Attrappe nicht auslöst.
|
|
$user = User::factory()->create(['email' => 'gesperrt@example.test']);
|
|
[$campaign, $recipient] = sendingCampaignWithRecipient($user);
|
|
|
|
app(EmailSuppressionService::class)->suppress($user->email, EmailSuppressionReason::HardBounce, 'test');
|
|
|
|
(new SendNewsletterCampaignMail($recipient->id))->handle();
|
|
|
|
expect($recipient->refresh()->status)->toBe(NewsletterRecipientStatus::Suppressed)
|
|
->and($recipient->sent_at)->toBeNull();
|
|
|
|
$campaign->refresh();
|
|
expect($campaign->sent_count)->toBe(0)
|
|
->and($campaign->failed_count)->toBe(0)
|
|
->and($campaign->skipped_count)->toBe(0)
|
|
->and($campaign->status)->toBe(NewsletterCampaignStatus::Sent);
|
|
});
|
|
|
|
test('newsletter:resume re-schedules pending recipients and optionally failed ones', function () {
|
|
/** @var TestCase $this */
|
|
Queue::fake();
|
|
|
|
$users = User::factory()->count(3)->create();
|
|
$campaign = NewsletterCampaign::factory()->create([
|
|
'status' => NewsletterCampaignStatus::Sending->value,
|
|
'recipients_total' => 3,
|
|
'failed_count' => 1,
|
|
]);
|
|
|
|
foreach ([NewsletterRecipientStatus::Pending, NewsletterRecipientStatus::Pending, NewsletterRecipientStatus::Failed] as $index => $status) {
|
|
$campaign->recipients()->create([
|
|
'user_id' => $users[$index]->id,
|
|
'email' => $users[$index]->email,
|
|
'status' => $status->value,
|
|
'error' => $status === NewsletterRecipientStatus::Failed ? 'SMTP weg' : null,
|
|
]);
|
|
}
|
|
|
|
$this->artisan('newsletter:resume', ['campaign' => $campaign->id])
|
|
->expectsOutputToContain('2 Empfänger neu eingeplant')
|
|
->assertSuccessful();
|
|
|
|
Queue::assertPushed(SendNewsletterCampaignMail::class, 2);
|
|
|
|
$this->artisan('newsletter:resume', ['campaign' => $campaign->id, '--failed' => true])
|
|
->assertSuccessful();
|
|
|
|
Queue::assertPushed(SendNewsletterCampaignMail::class, 5);
|
|
|
|
expect($campaign->recipients()->where('status', NewsletterRecipientStatus::Failed->value)->count())->toBe(0)
|
|
->and($campaign->recipients()->where('status', NewsletterRecipientStatus::Pending->value)->count())->toBe(3)
|
|
->and($campaign->recipients()->whereNotNull('error')->count())->toBe(0)
|
|
->and($campaign->refresh()->failed_count)->toBe(0);
|
|
});
|
|
|
|
test('newsletter:resume frees recipients stuck in sending after a crashed worker', function () {
|
|
/** @var TestCase $this */
|
|
Queue::fake();
|
|
|
|
$users = User::factory()->count(2)->create();
|
|
$campaign = NewsletterCampaign::factory()->create([
|
|
'status' => NewsletterCampaignStatus::Sending->value,
|
|
'recipients_total' => 2,
|
|
]);
|
|
|
|
$stale = $campaign->recipients()->create([
|
|
'user_id' => $users[0]->id,
|
|
'email' => $users[0]->email,
|
|
'status' => NewsletterRecipientStatus::Sending->value,
|
|
]);
|
|
$fresh = $campaign->recipients()->create([
|
|
'user_id' => $users[1]->id,
|
|
'email' => $users[1]->email,
|
|
'status' => NewsletterRecipientStatus::Sending->value,
|
|
]);
|
|
|
|
DB::table('newsletter_campaign_recipients')->where('id', $stale->id)->update(['updated_at' => now()->subMinutes(45)]);
|
|
DB::table('newsletter_campaign_recipients')->where('id', $fresh->id)->update(['updated_at' => now()->subMinutes(5)]);
|
|
|
|
$this->artisan('newsletter:resume', ['campaign' => $campaign->id])->assertSuccessful();
|
|
|
|
Queue::assertPushed(SendNewsletterCampaignMail::class, 1);
|
|
Queue::assertPushed(SendNewsletterCampaignMail::class, fn (SendNewsletterCampaignMail $job) => $job->recipientId === $stale->id);
|
|
|
|
expect($stale->refresh()->status)->toBe(NewsletterRecipientStatus::Pending)
|
|
->and($fresh->refresh()->status)->toBe(NewsletterRecipientStatus::Sending);
|
|
});
|
|
|
|
test('newsletter:resume refuses campaigns that are not sending', function () {
|
|
/** @var TestCase $this */
|
|
Queue::fake();
|
|
|
|
$draft = NewsletterCampaign::factory()->create();
|
|
|
|
$this->artisan('newsletter:resume', ['campaign' => $draft->id])->assertFailed();
|
|
|
|
Queue::assertNothingPushed();
|
|
});
|
|
|
|
test('the recipient protocol shows a stuck recipient as sending', function () {
|
|
$recipient = new NewsletterCampaignRecipient(['status' => NewsletterRecipientStatus::Sending->value]);
|
|
|
|
expect($recipient->status->label())->toBe('Wird versendet');
|
|
});
|
|
|
|
test('newsletter:resume refuses to continue while sending is disabled (E9)', function () {
|
|
/** @var TestCase $this */
|
|
config()->set('newsletter.sending_enabled', false);
|
|
$campaign = NewsletterCampaign::factory()->create(['status' => NewsletterCampaignStatus::Sending->value]);
|
|
|
|
$this->artisan('newsletter:resume', ['campaign' => $campaign->id])
|
|
->expectsOutputToContain('gesperrt')
|
|
->assertFailed();
|
|
});
|
|
|
|
test('a queued send job does not mail while sending is disabled and leaves the recipient pending (E9)', function () {
|
|
Mail::fake();
|
|
config()->set('newsletter.sending_enabled', false);
|
|
|
|
[, $recipient] = sendingCampaignWithRecipient(User::factory()->create());
|
|
|
|
(new SendNewsletterCampaignMail($recipient->id))->handle();
|
|
|
|
Mail::assertNothingSent();
|
|
expect($recipient->refresh()->status)->toBe(NewsletterRecipientStatus::Pending);
|
|
});
|