create(['email' => 'info@rattanhaus.de']); $alreadyOut = User::factory()->create(['email' => 'service@auto-expert24.de']); $alreadyOut->optOutOfEmailNotification(EmailNotificationType::Newsletter); $untouched = User::factory()->create(); $path = bounceFile(<<<'TXT' Nicht erreichbar INFO@rattanhaus.de service@auto-expert24.de citations2@locible.xyz TXT); $this->artisan('newsletter:import-bounces', ['file' => $path]) ->expectsOutputToContain('3 Adressen gelesen: 1 abgemeldet, 1 waren bereits abgemeldet, 1 ohne User-Konto.') ->expectsOutputToContain('Kein User-Konto: citations2@locible.xyz') ->assertExitCode(0); expect($bounced->fresh()->wantsEmailNotification(EmailNotificationType::Newsletter))->toBeFalse() ->and($untouched->fresh()->wantsEmailNotification(EmailNotificationType::Newsletter))->toBeTrue(); // Der Opt-out steht mit Methode „bounce“ im Aktivitäts-Verlauf. $event = ActivityEvent::query() ->where('type', ActivityType::EmailUnsubscribe->value) ->where('user_id', $bounced->id) ->sole(); expect($event->meta['method'])->toBe('bounce'); // Zweiter Lauf ist wirkungslos (idempotent, kein Doppel-Eintrag). $this->artisan('newsletter:import-bounces', ['file' => $path]) ->expectsOutputToContain('3 Adressen gelesen: 0 abgemeldet, 2 waren bereits abgemeldet, 1 ohne User-Konto.') ->assertExitCode(0); expect(ActivityEvent::query()->where('user_id', $bounced->id)->count())->toBe(1); unlink($path); }); test('the bounce import dry run changes nothing', function () { /** @var TestCase $this */ $user = User::factory()->create(['email' => 'info@flerbarshop.de']); $path = bounceFile("info@flerbarshop.de\n"); $this->artisan('newsletter:import-bounces', ['file' => $path, '--dry-run' => true]) ->expectsOutputToContain('[Dry-Run] 1 Adressen gelesen: 1 abgemeldet') ->assertExitCode(0); expect($user->fresh()->wantsEmailNotification(EmailNotificationType::Newsletter))->toBeTrue(); unlink($path); }); test('the bounce import fails cleanly for a missing file', function () { /** @var TestCase $this */ $this->artisan('newsletter:import-bounces', ['file' => '/gibt/es/nicht.md']) ->assertExitCode(1); }); test('the bounce import also unsubscribes the public newsletter signup', function () { /** @var TestCase $this */ // Kein User-Konto — genau der Fall, der bisher nur als „unbekannt“ // protokolliert und dann weiter angeschrieben wurde. $subscription = NewsletterSubscription::withoutGlobalScopes()->create([ 'portal' => Portal::Presseecho->value, 'email' => 'redaktion@tote-adresse.de', 'is_confirmed' => true, 'subscribed_at' => now()->subMonth(), 'unsubscribed_at' => null, ]); $path = bounceFile("Redaktion@Tote-Adresse.de\n"); $this->artisan('newsletter:import-bounces', ['file' => $path]) ->expectsOutputToContain('Newsletter-Anmeldungen: 1 abgemeldet, 0 waren bereits abgemeldet.') ->assertExitCode(0); expect($subscription->fresh()->unsubscribed_at)->not->toBeNull(); // Und die Adresse gilt nicht mehr als „ohne Konto“, weil sie sehr wohl // erreicht wurde. $this->artisan('newsletter:import-bounces', ['file' => $path]) ->expectsOutputToContain('1 Adressen gelesen: 0 abgemeldet, 0 waren bereits abgemeldet, 0 ohne User-Konto.') ->expectsOutputToContain('Newsletter-Anmeldungen: 0 abgemeldet, 1 waren bereits abgemeldet.') ->assertExitCode(0); unlink($path); }); test('the bounce import reaches every portal of an address', function () { /** @var TestCase $this */ // Die Anmeldung ist je Portal eindeutig; eine Adresse kann auf beiden // Portalen stehen. Der Lauf hat keinen Portal-Kontext und muss beide // treffen. foreach ([Portal::Presseecho, Portal::Businessportal24] as $portal) { NewsletterSubscription::withoutGlobalScopes()->create([ 'portal' => $portal->value, 'email' => 'beide@tote-adresse.de', 'is_confirmed' => true, 'subscribed_at' => now()->subMonth(), ]); } $path = bounceFile("beide@tote-adresse.de\n"); $this->artisan('newsletter:import-bounces', ['file' => $path]) ->expectsOutputToContain('Newsletter-Anmeldungen: 2 abgemeldet, 0 waren bereits abgemeldet.') ->assertExitCode(0); expect(NewsletterSubscription::withoutGlobalScopes()->whereNull('unsubscribed_at')->count())->toBe(0); unlink($path); }); test('the bounce import dry run leaves newsletter signups alone', function () { /** @var TestCase $this */ $subscription = NewsletterSubscription::withoutGlobalScopes()->create([ 'portal' => Portal::Presseecho->value, 'email' => 'probe@tote-adresse.de', 'is_confirmed' => true, 'subscribed_at' => now()->subMonth(), ]); $path = bounceFile("probe@tote-adresse.de\n"); $this->artisan('newsletter:import-bounces', ['file' => $path, '--dry-run' => true]) ->expectsOutputToContain('[Dry-Run] Newsletter-Anmeldungen: 1 abgemeldet') ->assertExitCode(0); expect($subscription->fresh()->unsubscribed_at)->toBeNull(); unlink($path); }); test('the bounce import ignores the letter case of the stored address', function () { /** @var TestCase $this */ // Die Adresse steht im Konto anders geschrieben als im Bounce-Log. $user = User::factory()->create(['email' => 'Presse@Gemischte-Schreibung.de']); $path = bounceFile("presse@gemischte-schreibung.de\n"); $this->artisan('newsletter:import-bounces', ['file' => $path]) ->expectsOutputToContain('1 Adressen gelesen: 1 abgemeldet') ->assertExitCode(0); expect($user->fresh()->wantsEmailNotification(EmailNotificationType::Newsletter))->toBeFalse(); unlink($path); });