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'); });