mail: Kontakterreichbarkeit - Formpruefung, Domainpruefung, Einlieferer-Bericht
Vier Punkte aus kontakterreichbarkeit-mx-bounces-2026-09-15.md, ohne Veroeffentlichungssperre: - PlausibleEmailDomain: Domain ohne Endung oder mit Tippfehler-TLD ist ein 422 an der Legacy-API, deterministisch ohne DNS. - email_domain_checks + MailDomainChecker ueber DnsResolver-Schnittstelle (RFC 5321 impliziter MX, RFC 7505 Null MX, Unresolved ist kein Befund), Observer an Company/Contact, Job nach Commit, taeglicher Refresh, mail:check-domains fuer den Bestand. Nie eine Adresssperre aus DNS. - Admin > Reports > Einlieferer-Bericht: tote Adressen aus Sperrliste und Domainbefund, automatisch abgelehnte Meldungen mit Grund, Text zum Kopieren und CSV. Versendet nichts. - EmailBounceKind::Policy: 5.7.x wird protokolliert, zaehlt aber nicht auf den Schwellwert. Statuscode entscheidet vor Diagnosetext. Messung vorab: 6 von 877 API-Domains ohne Zustellweg, eine davon fuer 66 der 151 Ruecklaeufer verantwortlich. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fddfd8a61c
commit
ada41cb067
34 changed files with 1985 additions and 25 deletions
|
|
@ -76,7 +76,7 @@ test('runKiCheck does nothing when no check is selected', function () {
|
|||
->call('runKiCheck')
|
||||
->assertHasNoErrors();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
Queue::assertNotPushed(ClassifyPressRelease::class);
|
||||
});
|
||||
|
||||
test('a non-routing classification job updates the verdict but leaves the status unchanged', function () {
|
||||
|
|
|
|||
|
|
@ -110,25 +110,26 @@ test('the free-text notice of our own relay is read and never mistaken for an au
|
|||
->and($suppressions->isSuppressed('info@example.org'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('policy rejections and full mailboxes count soft, not hard', function () {
|
||||
test('policy rejections and full mailboxes never count hard', function () {
|
||||
/** @var TestCase $this */
|
||||
// 5.7.1 „Relay access denied": die Adresse existiert, ihr Server nimmt
|
||||
// nur nichts an — erst der Schwellwert sperrt.
|
||||
// nur nichts an — protokollieren, nicht zählen.
|
||||
$result = app(BounceMailProcessor::class)->process(bounceFixture('policy-5.7.1'));
|
||||
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
|
||||
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Soft)
|
||||
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Policy)
|
||||
->and(EmailBounce::query()->first()->reference)->toBe('pm:99:company:7')
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('pr@example.net'))->toBeFalse();
|
||||
|
||||
$quota = str_replace(
|
||||
['Status: 5.7.1', '554 5.7.1 <pr@example.net>: Relay access denied', '<20260915120000.ABC@'],
|
||||
['Status: 5.0.0', '552 Requested mail action aborted: exceeded storage allocation', '<20260915120001.ABC@'],
|
||||
['Status: 5.2.2', '552 Requested mail action aborted: exceeded storage allocation', '<20260915120001.ABC@'],
|
||||
bounceFixture('policy-5.7.1'),
|
||||
);
|
||||
app(BounceMailProcessor::class)->process($quota);
|
||||
|
||||
expect(EmailBounce::query()->count())->toBe(2)
|
||||
->and(EmailBounce::query()->where('kind', EmailBounceKind::Soft->value)->count())->toBe(1)
|
||||
->and(EmailBounce::query()->where('kind', EmailBounceKind::Hard->value)->count())->toBe(0);
|
||||
});
|
||||
|
||||
|
|
@ -138,3 +139,17 @@ test('the fetch command does nothing without a configured mailbox', function ()
|
|||
|
||||
$this->artisan('mail:fetch-bounces')->expectsOutputToContain('nicht konfiguriert')->assertSuccessful();
|
||||
});
|
||||
|
||||
test('a policy rejection is logged but never counts toward suppression', function () {
|
||||
/** @var TestCase $this */
|
||||
// Vier Richtlinien-Ablehnungen: Der Schwellwert (3) darf sie nicht
|
||||
// erfassen — das Postfach kann einwandfrei existieren.
|
||||
$raw = bounceFixture('policy-5.7.1');
|
||||
|
||||
foreach (range(1, 4) as $i) {
|
||||
app(BounceMailProcessor::class)->process(str_replace('<20260915120000.ABC@', '<2026091512000'.$i.'.ABC@', $raw));
|
||||
}
|
||||
|
||||
expect(EmailBounce::query()->where('kind', EmailBounceKind::Policy->value)->count())->toBe(4)
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('pr@example.net'))->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
151
tests/Feature/MailDomainCheckTest.php
Normal file
151
tests/Feature/MailDomainCheckTest.php
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\EmailDomainState;
|
||||
use App\Jobs\CheckEmailDomain;
|
||||
use App\Models\Company;
|
||||
use App\Models\EmailDomainCheck;
|
||||
use App\Services\Mail\DnsResolver;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use App\Services\Mail\MailDomainChecker;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\Support\FakeDnsResolver;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* DNS-Prüfung der Mail-Domains (Kontakterreichbarkeit, Abschnitt 3 und 8).
|
||||
* Der Resolver ist gefälscht — kein Test fragt echtes DNS.
|
||||
*/
|
||||
function fakeDns(array $zones): MailDomainChecker
|
||||
{
|
||||
$resolver = new FakeDnsResolver($zones);
|
||||
app()->instance(DnsResolver::class, $resolver);
|
||||
|
||||
return app(MailDomainChecker::class);
|
||||
}
|
||||
|
||||
test('a usable mx means deliverable, without claiming the mailbox exists', function () {
|
||||
/** @var TestCase $this */
|
||||
$check = fakeDns([
|
||||
'firma.example' => ['MX' => [['target' => 'mx.firma.example', 'pri' => 10]]],
|
||||
'mx.firma.example' => ['A' => [['ip' => '203.0.113.5']]],
|
||||
])->check('firma.example');
|
||||
|
||||
expect($check->state)->toBe(EmailDomainState::Deliverable)
|
||||
->and($check->state->isDeliverable())->toBeTrue()
|
||||
->and($check->state->isBroken())->toBeFalse()
|
||||
->and($check->expires_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('no mx but an a record is the implicit mx fallback of rfc 5321', function () {
|
||||
/** @var TestCase $this */
|
||||
$check = fakeDns(['firma.example' => ['A' => [['ip' => '203.0.113.5']]]])->check('firma.example');
|
||||
|
||||
expect($check->state)->toBe(EmailDomainState::ImplicitMx)
|
||||
->and($check->state->isDeliverable())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a null mx says no mail, even with an a record present', function () {
|
||||
/** @var TestCase $this */
|
||||
// RFC 7505: `MX 0 .` — kein Zurückfallen auf die Webserver-IP.
|
||||
$check = fakeDns([
|
||||
'firma.example' => ['MX' => [['target' => '.', 'pri' => 0]], 'A' => [['ip' => '203.0.113.5']]],
|
||||
])->check('firma.example');
|
||||
|
||||
expect($check->state)->toBe(EmailDomainState::NullMx)
|
||||
->and($check->state->isBroken())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a domain without any records is missing, one with a zone but no mail path is not', function () {
|
||||
/** @var TestCase $this */
|
||||
$missing = fakeDns(['weg.example' => []])->check('weg.example');
|
||||
$noPath = fakeDns(['nurweb.example' => ['NS' => [['target' => 'ns1.example']]]])->check('nurweb.example');
|
||||
|
||||
expect($missing->state)->toBe(EmailDomainState::DomainMissing)
|
||||
->and($noPath->state)->toBe(EmailDomainState::NoMailPath);
|
||||
});
|
||||
|
||||
test('one usable mx target is enough even when a sibling is broken', function () {
|
||||
/** @var TestCase $this */
|
||||
$check = fakeDns([
|
||||
'firma.example' => ['MX' => [['target' => 'kaputt.example'], ['target' => 'mx2.example']]],
|
||||
'kaputt.example' => [],
|
||||
'mx2.example' => ['AAAA' => [['ipv6' => '2001:db8::1']]],
|
||||
])->check('firma.example');
|
||||
|
||||
expect($check->state)->toBe(EmailDomainState::Deliverable);
|
||||
});
|
||||
|
||||
test('mx targets that all fail to resolve are a broken configuration', function () {
|
||||
/** @var TestCase $this */
|
||||
$check = fakeDns([
|
||||
'firma.example' => ['MX' => [['target' => 'kaputt.example']]],
|
||||
'kaputt.example' => [],
|
||||
])->check('firma.example');
|
||||
|
||||
expect($check->state)->toBe(EmailDomainState::BrokenMx)
|
||||
->and($check->state->isBroken())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a resolver failure is no finding and is retried with growing distance', function () {
|
||||
/** @var TestCase $this */
|
||||
// Timeout/SERVFAIL darf nie als „Domain kaputt" gelten.
|
||||
$checker = fakeDns(['firma.example' => ['MX' => null]]);
|
||||
$first = $checker->check('firma.example');
|
||||
$firstExpiry = $first->expires_at;
|
||||
|
||||
expect($first->state)->toBe(EmailDomainState::Unresolved)
|
||||
->and($first->state->isBroken())->toBeFalse()
|
||||
->and($first->attempts)->toBe(1);
|
||||
|
||||
$second = $checker->check('firma.example', force: true);
|
||||
|
||||
expect($second->attempts)->toBe(2)
|
||||
->and($second->expires_at->greaterThan($firstExpiry))->toBeTrue();
|
||||
});
|
||||
|
||||
test('a temporary mx target failure does not become a broken configuration', function () {
|
||||
/** @var TestCase $this */
|
||||
$check = fakeDns([
|
||||
'firma.example' => ['MX' => [['target' => 'mx.example']]],
|
||||
'mx.example' => ['A' => null, 'AAAA' => null],
|
||||
])->check('firma.example');
|
||||
|
||||
expect($check->state)->toBe(EmailDomainState::Unresolved);
|
||||
});
|
||||
|
||||
test('a fresh result is reused and a correction re-checks the new domain', function () {
|
||||
/** @var TestCase $this */
|
||||
$resolver = new FakeDnsResolver(['firma.example' => []]);
|
||||
app()->instance(DnsResolver::class, $resolver);
|
||||
$checker = app(MailDomainChecker::class);
|
||||
|
||||
expect($checker->check('firma.example')->state)->toBe(EmailDomainState::DomainMissing);
|
||||
|
||||
// Domain repariert — der gespeicherte Befund gilt aber noch.
|
||||
$resolver->set('firma.example', 'A', [['ip' => '203.0.113.9']]);
|
||||
|
||||
expect($checker->check('firma.example')->state)->toBe(EmailDomainState::DomainMissing)
|
||||
->and($checker->check('firma.example', force: true)->state)->toBe(EmailDomainState::ImplicitMx)
|
||||
->and(EmailDomainCheck::query()->where('domain', 'firma.example')->first()->first_broken_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('saving a company queues a check for its mail domain, unchanged addresses do not', function () {
|
||||
/** @var TestCase $this */
|
||||
Queue::fake();
|
||||
|
||||
$company = Company::factory()->create(['email' => 'presse@firma.example']);
|
||||
Queue::assertPushed(CheckEmailDomain::class, fn ($job) => $job->domain === 'firma.example');
|
||||
|
||||
$company->update(['name' => 'Neuer Name']);
|
||||
Queue::assertPushed(CheckEmailDomain::class, 1);
|
||||
|
||||
$company->update(['email' => 'presse@andere.example']);
|
||||
Queue::assertPushed(CheckEmailDomain::class, fn ($job) => $job->domain === 'andere.example');
|
||||
});
|
||||
|
||||
test('the domain finding never suppresses an address', function () {
|
||||
/** @var TestCase $this */
|
||||
fakeDns(['firma.example' => []])->check('firma.example');
|
||||
|
||||
expect(app(EmailSuppressionService::class)->isSuppressed('presse@firma.example'))->toBeFalse();
|
||||
});
|
||||
69
tests/Feature/PlausibleEmailDomainTest.php
Normal file
69
tests/Feature/PlausibleEmailDomainTest.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\User;
|
||||
use App\Rules\PlausibleEmailDomain;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Formprüfung der Domain beim Einreichen (Kontakterreichbarkeit, 5.1) —
|
||||
* deterministisch, ohne Netz.
|
||||
*/
|
||||
function passesPlausibility(string $email): bool
|
||||
{
|
||||
return Validator::make(['email' => $email], ['email' => ['nullable', 'email', new PlausibleEmailDomain]])->passes();
|
||||
}
|
||||
|
||||
test('addresses without a real domain ending are rejected', function () {
|
||||
/** @var TestCase $this */
|
||||
// Alle drei stammen aus dem Bestand vom 15.09.2026 und bestehen
|
||||
// Laravels `email`-Regel anstandslos.
|
||||
expect(passesPlausibility('info@hoerwelt-basel'))->toBeFalse()
|
||||
->and(passesPlausibility('kontakt@hoerex'))->toBeFalse()
|
||||
->and(passesPlausibility('cks@kaeser-stroebelde'))->toBeFalse()
|
||||
->and(passesPlausibility('info@urbanbnb.ded'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('ordinary addresses pass, international ones included', function () {
|
||||
/** @var TestCase $this */
|
||||
expect(passesPlausibility('presse@firma.de'))->toBeTrue()
|
||||
->and(passesPlausibility('info@sub.firma.co.uk'))->toBeTrue()
|
||||
->and(passesPlausibility('info@münchen-presse.de'))->toBeTrue()
|
||||
->and(passesPlausibility('info@xn--mnchen-presse-mfb.de'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('malformed domains are rejected without asking dns', function () {
|
||||
/** @var TestCase $this */
|
||||
expect(passesPlausibility('info@-firma.de'))->toBeFalse()
|
||||
->and(passesPlausibility('info@firma-.de'))->toBeFalse()
|
||||
->and(passesPlausibility('info@firma..de'))->toBeFalse()
|
||||
->and(passesPlausibility('info@firma.12'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('the domain of an address is returned in dns form', function () {
|
||||
/** @var TestCase $this */
|
||||
expect(PlausibleEmailDomain::domainOf('Presse@Firma.DE'))->toBe('firma.de')
|
||||
->and(PlausibleEmailDomain::domainOf('info@münchen.de'))->toBe('xn--mnchen-3ya.de')
|
||||
->and(PlausibleEmailDomain::domainOf('ohne-at'))->toBeNull()
|
||||
->and(PlausibleEmailDomain::domainOf(null))->toBeNull();
|
||||
});
|
||||
|
||||
test('the legacy api refuses a company address whose domain cannot exist', function () {
|
||||
/** @var TestCase $this */
|
||||
Queue::fake();
|
||||
test()->seed(RolesAndPermissionsSeeder::class);
|
||||
$user = User::factory()->create(['is_active' => true, 'portal' => Portal::Businessportal24]);
|
||||
Sanctum::actingAs($user, ['press-releases:write']);
|
||||
|
||||
$this->postJson('/service/users/me/companies', [
|
||||
'company' => ['name' => 'Hörwelt Basel', 'email' => 'info@hoerwelt-basel'],
|
||||
])->assertStatus(422);
|
||||
|
||||
$this->postJson('/service/users/me/companies', [
|
||||
'company' => ['name' => 'Hörwelt Basel', 'email' => 'info@hoerwelt-basel.ch'],
|
||||
])->assertCreated();
|
||||
});
|
||||
|
|
@ -35,7 +35,7 @@ test('reclassifyIfClassified does nothing for a never-classified press release',
|
|||
|
||||
app(PressReleaseService::class)->reclassifyIfClassified($pressRelease);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
Queue::assertNotPushed(ClassifyPressRelease::class);
|
||||
});
|
||||
|
||||
test('reprocessAfterContentChange reclassifies and rescores on the fresh model state', function () {
|
||||
|
|
@ -63,7 +63,7 @@ test('reprocessAfterContentChange does nothing for an unclassified, unscored pre
|
|||
|
||||
app(PressReleaseService::class)->reprocessAfterContentChange($pressRelease);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
Queue::assertNotPushed(ClassifyPressRelease::class);
|
||||
});
|
||||
|
||||
test('api update of a classified press release re-classifies when the content changes', function () {
|
||||
|
|
@ -119,5 +119,5 @@ test('api update does not re-classify when content is unchanged', function () {
|
|||
'keywords' => 'neu',
|
||||
])->assertOk();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
Queue::assertNotPushed(ClassifyPressRelease::class);
|
||||
});
|
||||
|
|
|
|||
178
tests/Feature/SubmitterContactReportTest.php
Normal file
178
tests/Feature/SubmitterContactReportTest.php
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\EmailBounceKind;
|
||||
use App\Enums\EmailDomainState;
|
||||
use App\Enums\EmailSuppressionReason;
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\EmailDomainCheck;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Models\User;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use App\Services\Mail\SubmitterContactReport;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Einlieferer-Bericht (Kontakterreichbarkeit, Abschnitt 7): was ein
|
||||
* API-Einlieferer an seinen Daten reparieren muss.
|
||||
*/
|
||||
function submitterWithRelease(array $companyAttributes = [], ?string $rejectionReason = null): array
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create(['is_active' => true, 'portal' => Portal::Businessportal24]);
|
||||
$company = Company::factory()->create(array_merge(['portal' => Portal::Businessportal24], $companyAttributes));
|
||||
|
||||
$release = PressRelease::factory()
|
||||
->for($company)
|
||||
->create([
|
||||
'user_id' => $user->id,
|
||||
'portal' => Portal::Businessportal24,
|
||||
'status' => $rejectionReason !== null ? PressReleaseStatus::Rejected : PressReleaseStatus::Published,
|
||||
]);
|
||||
|
||||
PressReleaseStatusLog::query()->create([
|
||||
'press_release_id' => $release->id,
|
||||
'from_status' => 'draft',
|
||||
'to_status' => 'published',
|
||||
'source' => 'api',
|
||||
]);
|
||||
|
||||
if ($rejectionReason !== null) {
|
||||
PressReleaseStatusLog::query()->create([
|
||||
'press_release_id' => $release->id,
|
||||
'from_status' => 'review',
|
||||
'to_status' => 'rejected',
|
||||
'source' => 'ki',
|
||||
'reason' => $rejectionReason,
|
||||
]);
|
||||
}
|
||||
|
||||
return [$user, $company, $release];
|
||||
}
|
||||
|
||||
test('the report lists api submitters with their volume', function () {
|
||||
/** @var TestCase $this */
|
||||
[$user] = submitterWithRelease();
|
||||
|
||||
$submitters = app(SubmitterContactReport::class)->submitters();
|
||||
|
||||
expect($submitters)->toHaveCount(1)
|
||||
->and($submitters->first()->id)->toBe($user->id)
|
||||
->and($submitters->first()->releases)->toBe(1);
|
||||
});
|
||||
|
||||
test('a suppressed company address shows up as a broken contact', function () {
|
||||
/** @var TestCase $this */
|
||||
[$user, $company] = submitterWithRelease(['email' => 'presse@tot.example']);
|
||||
app(EmailSuppressionService::class)->recordBounce('presse@tot.example', EmailBounceKind::Hard, 'imap', 'm1');
|
||||
|
||||
$rows = app(SubmitterContactReport::class)->brokenContacts($user);
|
||||
|
||||
expect($rows)->toHaveCount(1)
|
||||
->and($rows->first()->email)->toBe('presse@tot.example')
|
||||
->and($rows->first()->fromDomain)->toBeFalse()
|
||||
->and($rows->first()->companyId)->toBe($company->id)
|
||||
->and($rows->first()->releases)->toBe(1);
|
||||
});
|
||||
|
||||
test('a domain without a mail path shows up even without any bounce', function () {
|
||||
/** @var TestCase $this */
|
||||
// Der Punkt der Vorabprüfung: sichtbar, bevor je eine Mail geschrieben wurde.
|
||||
[$user] = submitterWithRelease(['email' => 'info@existiert-nicht.example']);
|
||||
|
||||
EmailDomainCheck::query()->create([
|
||||
'domain' => 'existiert-nicht.example',
|
||||
'state' => EmailDomainState::DomainMissing,
|
||||
'detail' => 'Keine DNS-Einträge (NXDOMAIN)',
|
||||
'checked_at' => now(),
|
||||
'expires_at' => now()->addDay(),
|
||||
]);
|
||||
|
||||
$rows = app(SubmitterContactReport::class)->brokenContacts($user);
|
||||
|
||||
expect($rows)->toHaveCount(1)
|
||||
->and($rows->first()->fromDomain)->toBeTrue()
|
||||
->and($rows->first()->reason)->toBe('Domain existiert nicht')
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('info@existiert-nicht.example'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('an unresolved domain is no finding and stays out of the report', function () {
|
||||
/** @var TestCase $this */
|
||||
[$user] = submitterWithRelease(['email' => 'info@offen.example']);
|
||||
|
||||
EmailDomainCheck::query()->create([
|
||||
'domain' => 'offen.example',
|
||||
'state' => EmailDomainState::Unresolved,
|
||||
'checked_at' => now(),
|
||||
'expires_at' => now()->addMinutes(15),
|
||||
]);
|
||||
|
||||
expect(app(SubmitterContactReport::class)->brokenContacts($user))->toBeEmpty();
|
||||
});
|
||||
|
||||
test('press contacts of the submitter are covered, working addresses are not listed', function () {
|
||||
/** @var TestCase $this */
|
||||
[$user, $company] = submitterWithRelease(['email' => 'geht@example.com']);
|
||||
Contact::factory()->for($company)->create(['email' => 'kaputt@example.com']);
|
||||
|
||||
app(EmailSuppressionService::class)->suppress('kaputt@example.com', EmailSuppressionReason::HardBounce, 'import');
|
||||
|
||||
$rows = app(SubmitterContactReport::class)->brokenContacts($user);
|
||||
|
||||
expect($rows)->toHaveCount(1)
|
||||
->and($rows->first()->email)->toBe('kaputt@example.com');
|
||||
});
|
||||
|
||||
test('automatic rejections are listed with their reason', function () {
|
||||
/** @var TestCase $this */
|
||||
[$user] = submitterWithRelease([], 'K1: Unzulässige Gesundheitswerbung');
|
||||
|
||||
$rejected = app(SubmitterContactReport::class)->rejectedReleases($user);
|
||||
|
||||
expect($rejected)->toHaveCount(1)
|
||||
->and($rejected->first()->reason)->toBe('K1: Unzulässige Gesundheitswerbung');
|
||||
});
|
||||
|
||||
test('policy rejections are reported but never suppress the address', function () {
|
||||
/** @var TestCase $this */
|
||||
[$user] = submitterWithRelease(['email' => 'presse@gefiltert.example']);
|
||||
|
||||
foreach (range(1, 4) as $i) {
|
||||
app(EmailSuppressionService::class)->recordBounce(
|
||||
'presse@gefiltert.example',
|
||||
EmailBounceKind::Policy,
|
||||
'imap',
|
||||
'policy-'.$i,
|
||||
['status' => '5.7.1', 'diagnostic' => '554 5.7.1 Relay access denied'],
|
||||
);
|
||||
}
|
||||
|
||||
$report = app(SubmitterContactReport::class);
|
||||
|
||||
expect($report->policyRejections($user))->toHaveCount(4)
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('presse@gefiltert.example'))->toBeFalse()
|
||||
->and($report->brokenContacts($user))->toBeEmpty();
|
||||
});
|
||||
|
||||
test('the admin page renders the report for the submitter', function () {
|
||||
/** @var TestCase $this */
|
||||
test()->seed(RolesAndPermissionsSeeder::class);
|
||||
[$submitter] = submitterWithRelease(['email' => 'presse@tot.example', 'name' => 'Tote Firma AG'], 'K4: Falsche Tatsachenbehauptung');
|
||||
app(EmailSuppressionService::class)->recordBounce('presse@tot.example', EmailBounceKind::Hard, 'imap', 'm2');
|
||||
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.reports.submitter-contacts', ['submitter' => $submitter->id]))
|
||||
->assertSuccessful()
|
||||
->assertSeeText('presse@tot.example')
|
||||
->assertSeeText('Tote Firma AG')
|
||||
->assertSeeText('K4: Falsche Tatsachenbehauptung');
|
||||
});
|
||||
36
tests/Support/FakeDnsResolver.php
Normal file
36
tests/Support/FakeDnsResolver.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use App\Services\Mail\DnsResolver;
|
||||
|
||||
/**
|
||||
* DNS aus einer Tabelle statt aus dem Netz (Abnahmekriterium: keine
|
||||
* öffentlichen Resolver im Testlauf).
|
||||
*
|
||||
* `null` als Wert bedeutet Resolver-Fehler (Timeout/SERVFAIL), ein fehlender
|
||||
* Eintrag bedeutet „Domain antwortet, hat diesen Record aber nicht".
|
||||
*/
|
||||
class FakeDnsResolver implements DnsResolver
|
||||
{
|
||||
/**
|
||||
* @param array<string, array<string, list<array<string, mixed>>|null>> $zones
|
||||
*/
|
||||
public function __construct(private array $zones = []) {}
|
||||
|
||||
public function records(string $domain, string $type): ?array
|
||||
{
|
||||
$zone = $this->zones[mb_strtolower($domain)] ?? [];
|
||||
|
||||
if (! array_key_exists($type, $zone)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $zone[$type];
|
||||
}
|
||||
|
||||
public function set(string $domain, string $type, ?array $records): void
|
||||
{
|
||||
$this->zones[mb_strtolower($domain)][$type] = $records;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue