web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)

- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue
  Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt()
  für Sitemap-lastmod, dateModified und article:modified_time
- R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen
- R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt —
  hart wie weich — nur ein gültig signierter Rückläufer, unsignierte
  standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy)
- R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück
  (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und
  Unmerge, assignAll setzt veraltete Verweise zurück
- R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und
  verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach
  395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert,
  Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst
- N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap
- OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID

Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und
wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt
committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-02 13:09:00 +00:00
parent 1d417f87f7
commit c2e0b85242
48 changed files with 1676 additions and 134 deletions

View file

@ -4,7 +4,9 @@ use App\Enums\Portal;
use App\Models\Category;
use App\Models\PressRelease;
use App\Models\User;
use App\Support\WebPageCache;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Cache;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
@ -176,3 +178,21 @@ test('deactivating a category clears the cached public pages and its feed at onc
// Nicht erst nach Ablauf der Stale-Grenze: sofort weg.
$this->get('https://presseecho.test/de/category/handel/feed')->assertNotFound();
});
test('creating an active category clears the cached public pages at once', function () {
/** @var TestCase $this */
$generation = WebPageCache::generation();
Cache::put('web.llms.presseecho.test', 'alt', 3600);
LivewireVolt::test('admin.categories.create')
->set('portal', Portal::Presseecho->value)
->set('nameDe', 'Energie')
->set('nameEn', 'Energy')
->call('save')
->assertHasNoErrors();
// Umsetzungsaudit 29.09.2026, 5.2-Rest: Navigation und llms.txt zeigen
// die neue Branche sofort, nicht erst nach bis zu einer Stunde.
expect(WebPageCache::generation())->not->toBe($generation)
->and(Cache::has('web.llms.presseecho.test'))->toBeFalse();
});

View file

@ -301,3 +301,21 @@ test('api submissions without press contact address the company by name', functi
&& ! str_contains($html, 'Sebastian Einbock');
});
});
test('the legacy view url follows the language of the press release', function () {
/** @var TestCase $this */
$user = parityApiUser();
$company = Company::factory()->create(['portal' => Portal::Businessportal24]);
$pressRelease = PressRelease::factory()->create([
'user_id' => $user->id,
'company_id' => $company->id,
'portal' => Portal::Businessportal24,
'language' => 'en',
'status' => PressReleaseStatus::Published,
'published_at' => now()->subDay(),
]);
// Englische Meldungen liegen unter /en/ — /de/ antwortet für sie 404.
expect($this->getJson("/service/pressreleases/{$pressRelease->id}")->assertOk()->json('view_url'))
->toEndWith("/en/press-release/{$pressRelease->slug}");
});

View file

@ -5,6 +5,7 @@ use App\Enums\EmailBounceKind;
use App\Models\ActivityEvent;
use App\Models\EmailBounce;
use App\Services\Mail\BounceMailProcessor;
use App\Services\Mail\BounceReference;
use App\Services\Mail\DsnParser;
use App\Services\Mail\EmailSuppressionService;
use Tests\TestCase;
@ -18,6 +19,19 @@ function bounceFixture(string $name): string
return (string) file_get_contents(base_path("tests/Fixtures/bounces/{$name}.eml"));
}
/**
* Fixture, deren X-Pk-Ref wie beim echten Versand für `$recipient` signiert
* ist (Umsetzungsaudit 29.09.2026, R-04).
*/
function signedBounceFixture(string $name, string $recipient): string
{
return (string) preg_replace_callback(
'/^X-Pk-Ref: (.+)$/m',
fn (array $m): string => 'X-Pk-Ref: '.app(BounceReference::class)->sign(trim($m[1]), [$recipient]),
bounceFixture($name),
);
}
test('a postfix hard bounce yields recipient, status, diagnostic and our reference header', function () {
/** @var TestCase $this */
$report = app(DsnParser::class)->parse(bounceFixture('hard-5.1.1'));
@ -35,7 +49,7 @@ test('a postfix hard bounce yields recipient, status, diagnostic and our referen
test('processing a hard bounce suppresses the address and keeps the reference', function () {
/** @var TestCase $this */
$result = app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
$result = app(BounceMailProcessor::class)->process(signedBounceFixture('hard-5.1.1', 'presse@alte-firma.example'));
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
->and($result['bounces'])->toBe(1)
@ -44,13 +58,13 @@ test('processing a hard bounce suppresses the address and keeps the reference',
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Hard);
// Zweiter Durchlauf derselben Mail (abgebrochener Lauf): nichts doppelt.
app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
app(BounceMailProcessor::class)->process(signedBounceFixture('hard-5.1.1', 'presse@alte-firma.example'));
expect(EmailBounce::query()->count())->toBe(1);
});
test('a delayed report counts as a soft bounce and lower-case field names are fine', function () {
/** @var TestCase $this */
$result = app(BounceMailProcessor::class)->process(bounceFixture('delayed-4.4.1'));
$result = app(BounceMailProcessor::class)->process(signedBounceFixture('delayed-4.4.1', 'voll@example.test'));
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Soft)
@ -89,7 +103,7 @@ test('the utf-8 dsn variant of rfc 6533 is read like a classic report', function
->and($report->recipients[0]['status'])->toBe('5.0.0')
->and($report->reference)->toBe('user:4711');
$result = app(BounceMailProcessor::class)->process(bounceFixture('global-delivery-status'));
$result = app(BounceMailProcessor::class)->process(signedBounceFixture('global-delivery-status', 'weiterleitung@example.net'));
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Hard);
@ -104,10 +118,23 @@ test('the free-text notice of our own relay is read and never mistaken for an au
$result = app(BounceMailProcessor::class)->process(bounceFixture('kas-antispam'));
$suppressions = app(EmailSuppressionService::class);
// Der Freitext trägt keine Original-Header, also keine signierte
// Referenz: nur zur Sichtung protokolliert, zählt auf keinen
// Schwellwert (R-04, Umsetzungsaudit 02.10.2026, O01).
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
->and($result['bounces'])->toBe(2)
->and($suppressions->isSuppressed('presse@example.net'))->toBeTrue()
->and($suppressions->isSuppressed('info@example.org'))->toBeTrue();
->and(EmailBounce::query()->where('email', 'presse@example.net')->value('kind'))->toBe(EmailBounceKind::Policy)
->and($suppressions->isSuppressed('presse@example.net'))->toBeFalse();
// Schwellwert-Fenster (7 Tage) relativ zum Datum der Fixture.
$this->travelTo('2026-09-03 12:00:00');
foreach ([2, 3] as $i) {
app(BounceMailProcessor::class)->process(str_replace('844FE5F41C65@', '844FE5F41C6'.$i.'@', bounceFixture('kas-antispam')));
}
expect($suppressions->isSuppressed('presse@example.net'))->toBeFalse()
->and($suppressions->isSuppressed('info@example.org'))->toBeFalse();
});
test('policy rejections and full mailboxes never count hard', function () {
@ -124,7 +151,7 @@ test('policy rejections and full mailboxes never count hard', function () {
$quota = str_replace(
['Status: 5.7.1', '554 5.7.1 <pr@example.net>: Relay access denied', '<20260915120000.ABC@'],
['Status: 5.2.2', '552 Requested mail action aborted: exceeded storage allocation', '<20260915120001.ABC@'],
bounceFixture('policy-5.7.1'),
signedBounceFixture('policy-5.7.1', 'pr@example.net'),
);
app(BounceMailProcessor::class)->process($quota);
@ -153,3 +180,110 @@ test('a policy rejection is logged but never counts toward suppression', functio
expect(EmailBounce::query()->where('kind', EmailBounceKind::Policy->value)->count())->toBe(4)
->and(app(EmailSuppressionService::class)->isSuppressed('pr@example.net'))->toBeFalse();
});
test('a forged report without our signature never suppresses hard', function () {
/** @var TestCase $this */
// Jeder kann ein multipart/report ans Return-Path-Postfach schicken.
$result = app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
->and(EmailBounce::query()->sole()->kind)->toBe(EmailBounceKind::Policy)
->and(EmailBounce::query()->sole()->reference)->toBe('pm:4711:company:815 (unsigniert)')
->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse();
});
test('a report with a wrong or foreign signature is parked for review', function () {
/** @var TestCase $this */
// Signatur einer echten Mail an den Angreifer selbst, auf die Opferadresse umgeschrieben.
$replayed = signedBounceFixture('hard-5.1.1', 'angreifer@example.test');
$result = app(BounceMailProcessor::class)->process($replayed);
expect($result['bounces'])->toBe(1)
->and(EmailBounce::query()->sole()->kind)->toBe(EmailBounceKind::Policy)
->and(EmailBounce::query()->sole()->reference)->toBe('pm:4711:company:815 (Signatur ungültig)')
->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse();
// Beliebig oft wiederholt: Richtlinie zählt auf keinen Schwellwert.
foreach (range(1, 3) as $i) {
app(BounceMailProcessor::class)->process(str_replace('<20260915071233.ABC123@', '<2026091507123'.$i.'.ABC123@', $replayed));
}
expect(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse();
});
test('unsigned reports only count when the operator opts in, and then never hard', function () {
/** @var TestCase $this */
config()->set('bounces.unsigned_kind', 'soft');
app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
expect(EmailBounce::query()->sole()->kind)->toBe(EmailBounceKind::Soft)
->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse();
});
test('repeated unsigned or wrongly signed soft reports never suppress an address', function (string $variant) {
/** @var TestCase $this */
// Umsetzungsaudit 02.10.2026, O01: Der weiche Weg (delayed, failed 4.x)
// lief an der Signaturprüfung vorbei — drei gefälschte Berichte in
// sieben Tagen sperrten jede beliebige Adresse.
$raw = match ($variant) {
'delayed, unsigned' => bounceFixture('delayed-4.4.1'),
'delayed, foreign signature' => signedBounceFixture('delayed-4.4.1', 'angreifer@example.test'),
'failed 4.x, unsigned' => str_replace(['Action: delayed', 'action: delayed'], ['Action: failed', 'action: failed'], bounceFixture('delayed-4.4.1')),
};
$this->travelTo('2026-09-17 12:00:00');
foreach (range(1, 4) as $i) {
app(BounceMailProcessor::class)->process(str_replace('<delay-1@', '<delay-'.$i.'0@', $raw));
}
expect(EmailBounce::query()->count())->toBe(4)
->and(EmailBounce::query()->where('kind', '!=', EmailBounceKind::Policy->value)->count())->toBe(0)
->and(app(EmailSuppressionService::class)->isSuppressed('voll@example.test'))->toBeFalse();
})->with(['delayed, unsigned', 'delayed, foreign signature', 'failed 4.x, unsigned']);
test('validly signed soft reports still suppress at the threshold', function () {
/** @var TestCase $this */
// Schwellwert-Fenster (7 Tage) relativ zum Datum der Fixture.
$this->travelTo('2026-09-17 12:00:00');
foreach (range(1, 3) as $i) {
app(BounceMailProcessor::class)->process(str_replace('<delay-1@', '<delay-'.$i.'0@', signedBounceFixture('delayed-4.4.1', 'voll@example.test')));
}
expect(EmailBounce::query()->where('kind', EmailBounceKind::Soft->value)->count())->toBe(3)
->and(app(EmailSuppressionService::class)->isSuppressed('voll@example.test'))->toBeTrue();
});
test('the reference signature is bound to the recipient', function () {
$references = app(BounceReference::class);
$header = $references->sign('user:5', ['A@Example.test', 'b@example.test']);
expect($header)->toStartWith('user:5; s=')
->and($references->plain($header))->toBe('user:5')
->and($references->verify($header, 'a@example.test'))->toBeTrue()
->and($references->verify($header, 'b@example.test'))->toBeTrue()
->and($references->verify($header, 'c@example.test'))->toBeFalse()
->and($references->verify(str_replace('user:5', 'user:6', $header), 'a@example.test'))->toBeFalse()
->and($references->verify('user:5', 'a@example.test'))->toBeFalse()
->and($references->isSigned('user:5'))->toBeFalse();
});
test('bounce events are purged after the retention period while the suppression stays', function () {
/** @var TestCase $this */
app(BounceMailProcessor::class)->process(signedBounceFixture('hard-5.1.1', 'presse@alte-firma.example'));
app(BounceMailProcessor::class)->process(bounceFixture('delayed-4.4.1'));
EmailBounce::query()->where('email', 'presse@alte-firma.example')->update(['created_at' => now()->subDays(400)]);
$this->artisan('conversion:purge-events', ['--dry-run' => true])
->expectsOutputToContain('[Dry-Run] 1 Rückläufer-Ereignisse')
->assertSuccessful();
expect(EmailBounce::query()->count())->toBe(2);
// Umsetzungsaudit 29.09.2026, R-10: Adresse und Diagnosetext nach 395 Tagen weg.
$this->artisan('conversion:purge-events')->assertSuccessful();
expect(EmailBounce::query()->pluck('email')->all())->toBe(['voll@example.test'])
->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeTrue();
});

View file

@ -287,3 +287,76 @@ test('a soft-deleted source is no canonical target even while the copy still poi
->and(app(CanonicalAssigner::class)->canonicalUrlFor($pe, Portal::Presseecho))
->toBe('https://presseecho.test/de/press-release/doppel-standort');
});
test('assigning the backlog resets references that no longer form a pair', function () {
/** @var TestCase $this */
config()->set('canonical.enabled', true);
config()->set('canonical.portal_by_category', ['it' => 'businessportal24']);
[$pe, $b24] = duplicatePair('it');
[$pe2, $b242] = duplicatePair('it', ['slug' => 'zweites-paar', 'title' => 'Zweites Paar'], ['slug' => 'zweites-paar', 'title' => 'Zweites Paar']);
$assigner = app(CanonicalAssigner::class);
$assigner->assignAll();
expect($pe->fresh()->canonical_press_release_id)->toBe($b24->id)
->and($pe2->fresh()->canonical_press_release_id)->toBe($b242->id);
// Ohne Hook umgehängt (Query-Builder): die Kopie gehört jetzt einer anderen Firma.
DB::table('press_releases')->where('id', $pe->id)->update(['company_id' => Company::factory()->create()->id]);
// Zweites Paar: Text der Erstquelle geändert, ohne Neubewertung.
DB::table('press_releases')->where('id', $b242->id)->update(['content_hash' => str_repeat('a', 64)]);
$dry = $assigner->assignAll(dryRun: true);
expect($dry['reset'])->toBe(2)
->and($pe->fresh()->canonical_press_release_id)->toBe($b24->id);
$result = $assigner->assignAll();
expect($result['reset'])->toBe(2)
->and($pe->fresh()->canonical_press_release_id)->toBeNull()
->and($pe2->fresh()->canonical_press_release_id)->toBeNull();
$this->artisan(AssignCanonicalPressReleases::class)->assertSuccessful();
});
test('copies are re-evaluated when their source is archived by an admin (Audit N19-Rest)', function () {
/** @var TestCase $this */
Mail::fake();
config()->set('canonical.enabled', true);
config()->set('canonical.portal_by_category', ['it' => 'businessportal24']);
[$pe, $b24] = duplicatePair('it');
$assigner = app(CanonicalAssigner::class);
$assigner->assignFor($pe);
expect($pe->fresh()->canonical_press_release_id)->toBe($b24->id);
app(PressReleaseService::class)->archive($b24->fresh());
expect($pe->fresh()->canonical_press_release_id)->toBeNull()
->and($assigner->isCanonicalOn($pe->fresh(), Portal::Presseecho))->toBeTrue();
});
test('the mapping by category id survives a slug change and wins over the slug list', function () {
config()->set('canonical.enabled', true);
[$pe, $b24, $category] = duplicatePair('it');
config()->set('canonical.portal_by_category', ['it' => 'businessportal24']);
config()->set('canonical.portal_by_category_id', [$category->id => 'presseecho']);
$assigner = app(CanonicalAssigner::class);
expect($assigner->preferredPortal($pe))->toBe(Portal::Presseecho);
// Slug im Admin umbenannt: die ID-Zuordnung greift weiter.
$category->translations()->where('locale', 'de')->update(['slug' => 'informationstechnik']);
$fresh = app()->make(CanonicalAssigner::class);
expect($fresh->preferredPortal($pe))->toBe(Portal::Presseecho);
// Unterkategorien folgen der Hauptkategorie.
$child = Category::factory()->create(['portal' => Portal::Both, 'parent_id' => $category->id]);
expect($fresh->preferredPortalForCategory($child->id))->toBe(Portal::Presseecho)
->and($fresh->categoryIdsPreferring(Portal::Presseecho))->toContain($category->id, $child->id);
// Ohne ID-Eintrag: rückwärtskompatibel über den DE-Slug.
config()->set('canonical.portal_by_category_id', []);
config()->set('canonical.portal_by_category', ['informationstechnik' => 'businessportal24']);
expect(app()->make(CanonicalAssigner::class)->preferredPortal($pe))->toBe(Portal::Businessportal24);
});

View file

@ -3,6 +3,7 @@
use App\Enums\CompanyAddressClass;
use App\Enums\CompanyMergeSubject;
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\CompanyEmailGroup;
use App\Models\CompanyMerge;
@ -12,6 +13,7 @@ use App\Models\User;
use App\Services\Company\CompanyMergePlan;
use App\Services\Company\CompanyMergeService;
use App\Services\Company\CompanyMergeSkip;
use App\Services\Company\MergedNewsroomLookup;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
@ -345,3 +347,118 @@ test('the merge list shows survivor, merged companies and the URLs to check', fu
->expectsOutputToContain('muster-gmbh')
->assertSuccessful();
});
test('merge and unmerge leave updated_at of the moved releases untouched', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
mergeMember('info@muster.de', Portal::Presseecho, 3);
$loser = mergeMember('info@muster.de', Portal::Businessportal24, 1);
$release = PressRelease::withoutGlobalScopes()->where('company_id', $loser->id)->sole();
$before = $release->updated_at->toDateTimeString();
$this->travel(3)->days();
$service = app(CompanyMergeService::class);
$merge = $service->merge(planFor('info@muster.de'), 'b');
expect(PressRelease::withoutGlobalScopes()->find($release->id)->updated_at->toDateTimeString())->toBe($before);
$service->revert($merge);
expect(PressRelease::withoutGlobalScopes()->find($release->id))
->company_id->toBe($loser->id)
->updated_at->toDateTimeString()->toBe($before);
});
test('unmerge keeps a field the customer changed after the merge', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 3, ['website' => null, 'boilerplate' => null, 'phone' => null]);
mergeMember('info@muster.de', Portal::Businessportal24, 0, ['website' => 'https://muster.de', 'boilerplate' => 'Über Muster', 'phone' => '030 2']);
$service = app(CompanyMergeService::class);
$merge = $service->merge(planFor('info@muster.de'), 'b');
expect($merge->moves()->where('field', 'boilerplate')->value('merged_value'))->toBe('Über Muster');
// Der Kunde beansprucht den Newsroom und schreibt den Text neu.
$survivor->refresh()->update(['boilerplate' => 'Unser eigener Text']);
$service->revert($merge);
$survivor->refresh();
expect($survivor->boilerplate)->toBe('Unser eigener Text')
->and($survivor->website)->toBeNull()
->and($survivor->phone)->toBeNull();
});
test('unmerge of an older merge without stored fill value compares against the merged company', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 3, ['website' => null, 'boilerplate' => null]);
mergeMember('info@muster.de', Portal::Businessportal24, 0, ['website' => 'https://muster.de', 'boilerplate' => 'Über Muster']);
$service = app(CompanyMergeService::class);
$merge = $service->merge(planFor('info@muster.de'), 'b');
// Stand vor der Migration: kein Füllwert in der Move-Zeile.
$merge->moves()->update(['merged_value' => null]);
$survivor->refresh()->update(['boilerplate' => 'Unser eigener Text']);
$service->revert($merge);
$survivor->refresh();
expect($survivor->boilerplate)->toBe('Unser eigener Text')
->and($survivor->website)->toBeNull();
});
test('merge and unmerge re-evaluate the canonical assignment of the moved releases', function () {
/** @var TestCase $this */
config()->set('canonical.enabled', true);
config()->set('canonical.portal_by_category', ['it' => 'businessportal24']);
$category = Category::factory()->create(['portal' => Portal::Both]);
$category->translations()->create(['locale' => 'de', 'name' => 'IT', 'slug' => 'it']);
mergeGroup('info@muster.de');
$survivor = mergeMember('info@muster.de', Portal::Businessportal24, 2);
$loser = mergeMember('info@muster.de', Portal::Presseecho);
$source = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->for($category)
->create(['company_id' => $survivor->id, 'title' => 'Neuer Standort', 'text' => '<p>Gleicher Text</p>']);
$copy = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)
->create(['company_id' => $loser->id, 'title' => 'Neuer Standort', 'text' => '<p>Gleicher Text</p>']);
// Verschiedene Firmen: kein Paar.
expect($copy->fresh()->canonical_press_release_id)->toBeNull();
$service = app(CompanyMergeService::class);
$merge = $service->merge(planFor('info@muster.de'), 'b');
expect($copy->fresh()->canonical_press_release_id)->toBe($source->id)
->and($source->fresh()->canonical_press_release_id)->toBeNull();
$service->revert($merge);
// Wieder verschiedene Firmen: der Verweis auf die fremde Meldung fällt.
expect($copy->fresh()->canonical_press_release_id)->toBeNull()
->and($source->fresh()->canonical_press_release_id)->toBeNull();
});
test('an old newsroom slug follows a chain of merges to the current survivor', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$middle = mergeMember('info@muster.de', Portal::Presseecho, 2, ['slug' => 'muster-mitte']);
mergeMember('info@muster.de', Portal::Presseecho, 1, ['slug' => 'muster-alt']);
$service = app(CompanyMergeService::class);
$service->merge(planFor('info@muster.de'), 'b1');
// Später: Die Mitte geht ihrerseits in einer weiteren Firma auf.
$final = mergeMember('info@muster.de', Portal::Presseecho, 5, ['slug' => 'muster-neu']);
PressRelease::withoutGlobalScopes()->where('company_id', $final->id)->update(['published_at' => now()->subDay()]);
$service->merge(planFor('info@muster.de'), 'b2');
expect(Company::withTrashed()->find($middle->id)->trashed())->toBeTrue()
->and(app(MergedNewsroomLookup::class)->survivorFor(['presseecho', 'both'], 'muster-alt')?->id)->toBe($final->id);
$this->get('https://presseecho.test/de/newsroom/muster-alt')
->assertRedirect('https://presseecho.test/de/newsroom/muster-neu')
->assertStatus(301);
});

View file

@ -267,3 +267,33 @@ test('the ladder is complete and ordered', function () {
expect($steps)->toBe([1, 2, 3, 4, 5])
->and(ConversionEventType::NewsroomEdited->diagnosis())->toBe('Kein Anreiz, das Profil zu pflegen');
});
test('mail link clicks are throttled per address and release and ignore link scanners', function () {
/** @var TestCase $this */
$company = Company::factory()->create();
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)
->create(['company_id' => $company->id, 'published_at' => now()->subDay()]);
$url = 'https://businessportal24.test/de/press-release/'.$release->slug.'?src='.ConversionSource::MailPublish->value.'&pr='.$release->id;
// Umsetzungsaudit 29.09.2026, R-08: Link-Scanner der Mailserver holen
// Links vorab — per HEAD oder mit eigener Kennung.
$this->call('HEAD', $url)->assertOk();
$this->withHeaders(['User-Agent' => 'Mozilla/5.0 (compatible; Barracuda Sentinel)'])->get($url)->assertOk();
$this->withHeaders(['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) BingPreview/1.0b'])->get($url)->assertOk();
expect(ConversionEvent::query()->count())->toBe(0);
// Derselbe Klick mehrfach (Doppelklick, erneutes Öffnen): einmal gezählt.
$browser = ['User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Safari/605.1.15'];
$this->withHeaders($browser)->get($url)->assertOk();
$this->withHeaders($browser)->get($url)->assertOk();
$this->withHeaders($browser)->get($url)->assertOk();
expect(ConversionEvent::query()->where('type', ConversionEventType::MailLinkClicked->value)->count())->toBe(1);
// Eine andere Meldung aus derselben Mail zählt eigenständig.
$other = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)
->create(['company_id' => $company->id, 'published_at' => now()->subDay()]);
$this->withHeaders($browser)->get('https://businessportal24.test/de/press-release/'.$other->slug.'?src='.ConversionSource::MailPublish->value.'&pr='.$other->id)->assertOk();
expect(ConversionEvent::query()->count())->toBe(2);
});

View file

@ -16,6 +16,7 @@ use App\Models\EmailSuppression;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Models\User;
use App\Services\Mail\BounceReference;
use App\Services\Mail\EmailSuppressionService;
use App\Services\Mail\MailCircuitBreaker;
use App\Services\Mail\SmtpFailureClassifier;
@ -130,7 +131,22 @@ test('the gate keeps the other recipients and sets the return path', function ()
expect($sent)->toHaveCount(1)
->and(array_map(fn ($a) => $a->getAddress(), $email->getTo()))->toBe(['lebt@example.test'])
->and($email->getReturnPath()?->getAddress())->toBe('bounce@pressekonto.test')
->and($email->getHeaders()->get('X-Pk-Ref')?->getBodyAsString())->toBe('user:'.$user->id);
->and($email->getHeaders()->get('X-Pk-Ref')?->getBodyAsString())->toStartWith('user:'.$user->id.'; s=');
// Signiert je verbliebenem Empfänger (Umsetzungsaudit 29.09.2026, R-04).
$reference = $email->getHeaders()->get('X-Pk-Ref')?->getBodyAsString();
expect(app(BounceReference::class)->verify($reference, 'lebt@example.test'))->toBeTrue()
->and(app(BounceReference::class)->verify($reference, 'tot@example.test'))->toBeFalse();
});
test('the gate signs a reference on mails without their own', function () {
/** @var TestCase $this */
Mail::raw('Hallo', fn ($message) => $message->to('kunde@example.test')->subject('Test'));
$reference = app('mailer')->getSymfonyTransport()->messages()[0]->getOriginalMessage()->getHeaders()->get('X-Pk-Ref')?->getBodyAsString();
expect($reference)->toStartWith('mail; s=')
->and(app(BounceReference::class)->verify($reference, 'kunde@example.test'))->toBeTrue();
});
// ------------------------------------------------- Pfad 2: SMTP-Ablehnung

View file

@ -3,6 +3,7 @@
use App\Console\Commands\CheckIndexStatus;
use App\Enums\IndexState;
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseIndexCheck;
@ -230,3 +231,21 @@ test('the distribution counts states over the same population as the total, one
->and($bucket['unchecked'])->toBe(0)
->and($states + $bucket['unchecked'])->toBe($bucket['total']);
});
test('a release on both portals counts as copy on the portal that does not lead, like the sitemap', function () {
/** @var TestCase $this */
config()->set('canonical.enabled', true);
config()->set('canonical.portal_by_category', ['handel' => 'businessportal24']);
$category = Category::factory()->create(['portal' => Portal::Both]);
$category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']);
publishedOn(Portal::Both, 5, ['category_id' => $category->id]);
$distribution = app(IndexStatusReport::class)->distribution();
// Umsetzungsaudit 29.09.2026, N18-Rest: die Sitemap von presseecho führt sie nicht.
expect($distribution['businessportal24']['direct']['source']['total'])->toBe(1)
->and($distribution['businessportal24']['direct']['copy']['total'])->toBe(0)
->and($distribution['presseecho']['direct']['source']['total'])->toBe(0)
->and($distribution['presseecho']['direct']['copy']['total'])->toBe(1);
});

View file

@ -8,6 +8,7 @@ use App\Models\PressRelease;
use App\Models\PressReleaseCrawlerHit;
use App\Models\PressReleaseEvent;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
@ -125,3 +126,25 @@ test('the purge reads the watermark from the database, not from a stale cache (A
expect(PressReleaseEvent::query()->find($old->id))->toBeNull()
->and(Cache::get('app-setting.'.AggregatePressReleaseViews::WatermarkKey))->toBe(0);
});
test('the purge deletes by id range below a boundary instead of scanning created_at', function () {
/** @var TestCase $this */
$release = PressRelease::factory()->create();
$old = collect(range(1, 3))->map(fn () => purgeTestEvent($release, now()->subDays(400)->toDateTimeString()));
$recent = purgeTestEvent($release, now()->subDays(10)->toDateTimeString());
AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, $recent->id);
DB::enableQueryLog();
$this->artisan('press-releases:purge-events', ['--chunk' => 100])->assertSuccessful();
// Umsetzungsaudit 29.09.2026, OPT-2: press_release_events hat keinen
// Index auf created_at — gelöscht wird nach Primärschlüssel.
$deletes = collect(DB::getQueryLog())->pluck('query')
->filter(fn (string $sql): bool => str_starts_with(strtolower($sql), 'delete') && str_contains($sql, 'press_release_events'));
expect($deletes)->not->toBeEmpty()
->and($deletes->every(fn (string $sql): bool => ! str_contains($sql, 'created_at')))->toBeTrue()
->and(PressReleaseEvent::query()->whereIn('id', $old->pluck('id'))->count())->toBe(0)
->and(PressReleaseEvent::query()->find($recent->id))->not->toBeNull();
});

View file

@ -234,3 +234,26 @@ test('the release summary reports the sitemap role: the source is listed, a copy
config()->set('canonical.enabled', false);
expect($stats->releaseSummary($copy)['sitemap_role'])->toBe('source');
});
test('the listing date is not claimed when the release has a counterpart on the other portal', function () {
/** @var TestCase $this */
[$customer, $company] = claimant();
config()->set('canonical.enabled', true);
// Fassung auf dem anderen Portal, inzwischen depubliziert: Die Kopie ist
// erst seitdem Erstquelle — wann genau, ist nicht gespeichert (N17-Rest).
$former = PressRelease::factory()->forPortal(Portal::Presseecho)->create(['company_id' => $company->id, 'title' => 'Gleich', 'text' => 'Gleicher Text', 'status' => 'archived', 'published_at' => now()->subDays(20)]);
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'user_id' => $customer->id, 'title' => 'Gleich', 'text' => 'Gleicher Text', 'published_at' => now()->subDays(10)]);
expect($former->content_hash)->toBe($release->content_hash);
$summary = app(VisibilityStats::class)->releaseSummary($release);
expect($summary['sitemap_role'])->toBe('source')
->and($summary['in_sitemap_since'])->toBeNull()
->and($summary['sitemap_portals'])->toBe([Portal::Businessportal24->label()]);
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])
->assertSeeText('In der Sitemap gelistet (Businessportal24)')
->assertDontSeeText('Gelistet seit')
->assertDontSeeText('Derzeit nicht gelistet');
});

View file

@ -0,0 +1,112 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleaseEventType;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseEvent;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
/**
* Umsetzungsaudit 29.09.2026, R-02/K-03 und R-03: `updated_at` taugt nicht
* als Änderungsdatum (Aufrufaggregation, Merge). Sitemap-lastmod, JSON-LD
* dateModified und article:modified_time folgen `content_changed_at`, die
* Sitemap führt englische Meldungen unter /en/.
*/
function lastmodRelease(array $attributes = []): PressRelease
{
$category = Category::factory()->create(['portal' => Portal::Presseecho, 'is_active' => true]);
$company = Company::factory()->presseecho()->create(['is_active' => true]);
return PressRelease::factory()
->published()
->forPortal(Portal::Presseecho)
->for($category)
->for($company)
->create(array_merge(['slug' => 'lastmod-pm', 'published_at' => Carbon::parse('2026-01-10 10:00:00')], $attributes));
}
test('view aggregation leaves updated_at untouched', function () {
/** @var TestCase $this */
$release = lastmodRelease(['hits' => 0]);
$before = PressRelease::withoutGlobalScopes()->find($release->id)->updated_at;
PressReleaseEvent::factory()->count(3)->create([
'press_release_id' => $release->id,
'type' => PressReleaseEventType::View,
]);
$this->travel(2)->days();
$this->artisan('press-releases:aggregate-views')->assertSuccessful();
$fresh = PressRelease::withoutGlobalScopes()->find($release->id);
expect($fresh->hits)->toBe(3)
->and($fresh->updated_at->equalTo($before))->toBeTrue();
});
test('a text change sets content_changed_at, other saves do not', function () {
$release = lastmodRelease();
expect($release->fresh()->content_changed_at)->toBeNull();
$this->travel(1)->days();
$release->update(['keywords' => 'nur, schlagworte']);
expect($release->fresh()->content_changed_at)->toBeNull();
$this->travel(1)->days();
$release->update(['text' => '<p>Neuer Text</p>']);
expect($release->fresh()->content_changed_at?->toDateTimeString())->toBe(now()->toDateTimeString());
});
test('sitemap lastmod follows content_changed_at and ignores updated_at', function () {
/** @var TestCase $this */
$release = lastmodRelease();
// Aufrufaggregation / Merge alter Art: updated_at springt, Inhalt nicht.
PressRelease::withoutGlobalScopes()->whereKey($release->id)->toBase()->update(['updated_at' => '2026-09-20 12:00:00']);
Cache::flush();
$xml = $this->get('https://presseecho.test/sitemap.xml')->assertSuccessful()->getContent();
expect($xml)->toContain('<lastmod>'.Carbon::parse('2026-01-10 10:00:00')->toAtomString().'</lastmod>')
->not->toContain('2026-09-20');
PressRelease::withoutGlobalScopes()->whereKey($release->id)->toBase()->update(['content_changed_at' => '2026-03-05 08:00:00']);
Cache::flush();
$xml = $this->get('https://presseecho.test/sitemap.xml')->assertSuccessful()->getContent();
expect($xml)->toContain('<lastmod>'.Carbon::parse('2026-03-05 08:00:00')->toAtomString().'</lastmod>');
});
test('the detail page reports content_changed_at as modification date', function () {
/** @var TestCase $this */
$release = lastmodRelease();
PressRelease::withoutGlobalScopes()->whereKey($release->id)->toBase()->update(['updated_at' => '2026-09-20 12:00:00']);
$html = $this->get('https://presseecho.test/de/press-release/lastmod-pm')->assertSuccessful()->getContent();
expect($html)->not->toContain('article:modified_time')
->not->toContain('2026-09-20T12:00:00');
PressRelease::withoutGlobalScopes()->whereKey($release->id)->toBase()->update(['content_changed_at' => '2026-03-05 08:00:00']);
Cache::flush();
$html = $this->get('https://presseecho.test/de/press-release/lastmod-pm')->assertSuccessful()->getContent();
$modified = Carbon::parse('2026-03-05 08:00:00')->toIso8601String();
expect($html)->toContain('<meta property="article:modified_time" content="'.$modified.'">')
->toContain('"dateModified":"'.str_replace('/', '\/', $modified).'"');
});
test('the sitemap lists english releases under the english edition', function () {
/** @var TestCase $this */
lastmodRelease(['slug' => 'english-release', 'language' => 'en']);
lastmodRelease(['slug' => 'deutsche-meldung', 'language' => 'de']);
$xml = $this->get('https://presseecho.test/sitemap.xml')->assertSuccessful()->getContent();
expect($xml)->toContain('<loc>https://presseecho.test/en/press-release/english-release</loc>')
->toContain('<loc>https://presseecho.test/de/press-release/deutsche-meldung</loc>')
->not->toContain('/de/press-release/english-release');
$this->get('https://presseecho.test/en/press-release/english-release')->assertSuccessful();
});

View file

@ -8,6 +8,7 @@ use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseCrawlerHit;
use App\Models\PressReleaseEvent;
use App\Services\PressRelease\CrawlerHitReport;
use App\Support\CrawlerIdentifier;
use Tests\TestCase;
@ -124,3 +125,22 @@ test('the crawler report aggregates fetches by service, by day and by release',
->and($top[0]['fetches'])->toBe(4)
->and($top[0]['services'])->toBe(2);
});
test('a fetch by a known crawler is not counted as a view', function () {
/** @var TestCase $this */
$release = makeCrawlerLogRelease();
$this->withHeaders(['User-Agent' => 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'])
->get('https://presseecho.test/de/press-release/crawler-protokoll-pm')
->assertSuccessful();
// Umsetzungsaudit 29.09.2026, R-07: Crawler-Abruf ja, Aufruf nein.
expect(PressReleaseCrawlerHit::query()->count())->toBe(1)
->and(PressReleaseEvent::query()->where('press_release_id', $release->id)->count())->toBe(0);
$this->withHeaders(['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36'])
->get('https://presseecho.test/de/press-release/crawler-protokoll-pm')
->assertSuccessful();
expect(PressReleaseEvent::query()->where('press_release_id', $release->id)->count())->toBe(1);
});

View file

@ -12,6 +12,7 @@ use App\Models\User;
use App\Services\PressRelease\FeedFetchReport;
use App\Services\PressRelease\VisibilityStats;
use App\Support\FeedReaderIdentifier;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
@ -162,3 +163,42 @@ test('the reader summary keeps feeds apart by portal and language', function ()
->and($feedly['feeds'])->toBe(3)
->and($feedly['subscribers'])->toBe(35);
});
test('the report groups and limits in sql: one query for top feeds and for the per-release counts', function () {
/** @var TestCase $this */
FeedFetch::factory()->category('handel')->reader('feedly', 12)->count(3)->create();
FeedFetch::factory()->category('handel')->reader(null)->create();
FeedFetch::factory()->newsroom('nordwind')->reader('feedly', 2)->create();
FeedFetch::factory()->reader('inoreader', 5)->count(2)->create();
FeedFetch::factory()->reader('inoreader', 5)->create(['language' => 'en']);
FeedFetch::factory()->category('handel')->create(['created_at' => now()->subDays(40)]);
$report = app(FeedFetchReport::class);
DB::enableQueryLog();
$top = $report->topFeeds(30, limit: 2);
$feedQueries = collect(DB::getQueryLog())->pluck('query')->filter(fn (string $sql): bool => str_contains($sql, 'feed_fetches'));
// Umsetzungsaudit 29.09.2026, OPT-1.
expect($feedQueries)->toHaveCount(1)
->and(strtolower((string) $feedQueries->first()))->toContain('limit')
->and(collect($top)->pluck('fetches')->all())->toBe([4, 2])
->and($top[0]['readers'])->toBe(2)
->and($top[0]['subscribers'])->toBe(12);
DB::flushQueryLog();
$counts = $report->fetchesFor([
['feed' => FeedFetch::FEED_CATEGORY, 'feed_key' => 'handel', 'portal' => 'presseecho', 'language' => 'de'],
['feed' => FeedFetch::FEED_NEWSROOM, 'feed_key' => 'nordwind', 'portal' => 'presseecho', 'language' => 'de'],
['feed' => FeedFetch::FEED_PORTAL, 'feed_key' => null, 'portal' => 'presseecho', 'language' => 'de'],
['feed' => FeedFetch::FEED_PORTAL, 'feed_key' => null, 'portal' => 'businessportal24', 'language' => 'de'],
]);
expect(DB::getQueryLog())->toHaveCount(1)
->and($counts)->toBe([
'category:handel:presseecho:de' => 4,
'newsroom:nordwind:presseecho:de' => 1,
'portal::presseecho:de' => 2,
'portal::businessportal24:de' => 0,
]);
});

View file

@ -225,3 +225,15 @@ test('the feed is cached with the page data and cleared with the public pages',
expect(Cache::has(WebPageCache::key('feed', ['presseecho', 'de', 'category', 'handel'])))->toBeFalse();
});
test('the feed teaser decodes stored entities before escaping them once', function () {
/** @var TestCase $this */
$category = feedCategory();
feedRelease($category, 'entities', ['text' => '<p>Müller &amp; Söhne &quot;neu&quot; &ndash; jetzt</p>']);
$xml = (string) $this->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful()->getContent();
expect($xml)->toContain('<description>Müller &amp; Söhne &quot;neu&quot; – jetzt</description>')
->not->toContain('&amp;amp;')
->not->toContain('&amp;quot;');
});

View file

@ -2,6 +2,8 @@
use App\Enums\Portal;
use App\Models\Category;
use App\Support\WebCaches;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
/**
@ -43,3 +45,14 @@ test('the hub serves llms.txt that points to both portals', function () {
$this->assertStringNotContainsString('press-release/{slug}', $text);
});
test('llms.txt is part of the public page caches that a content change clears', function () {
/** @var TestCase $this */
$this->get('https://presseecho.test/llms.txt')->assertSuccessful();
expect(Cache::has('web.llms.presseecho.test'))->toBeTrue();
WebCaches::forgetPublicPages();
// Umsetzungsaudit 29.09.2026, R-11: sonst bis zu 1 h alte Kategorienliste.
expect(Cache::has('web.llms.presseecho.test'))->toBeFalse();
});

View file

@ -230,3 +230,25 @@ test('a claim from the publish mail lands on that release with its views, a fore
$this->post(claimUrl(), ['pr' => $foreign->id])->assertSessionHas('newsroom-claim-status');
expect(MagicLink::query()->where('user_id', $user->id)->latest('id')->firstOrFail()->payload)->toBe(['company_id' => $company->id]);
});
test('attempts without a sent link do not use up the daily links of the newsroom', function () {
/** @var TestCase $this */
$company = claimCompany(['email' => null]);
RateLimiter::clear('newsroom-claim|company|'.$company->id);
config()->set('newsroom.claim.links_per_day', 2);
// Umsetzungsaudit 29.09.2026, R-09: Ohne Versand zählt der Firmenzähler
// nicht — Dritte können den Inhaber so nicht aussperren.
foreach (range(1, 3) as $i) {
$this->post(claimUrl())->assertSessionHas('newsroom-claim-none')->assertSessionHasNoErrors();
}
$company->update(['email' => 'info@muster.de']);
$this->post(claimUrl())->assertSessionHas('newsroom-claim-status')->assertSessionHasNoErrors();
Mail::assertQueuedCount(1);
});
test('the default daily link budget per newsroom is moderate', function () {
expect((int) config('newsroom.claim.links_per_day'))->toBe(5);
});