presseportale/tests/Feature/CanonicalAssignmentTest.php
Kevin Adametz d6e49c11c3 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>
2026-10-02 13:09:00 +00:00

362 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Console\Commands\AssignCanonicalPressReleases;
use App\Console\Commands\HashPressReleaseContent;
use App\Console\Commands\ReportDuplicatePressReleases;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseIndexCheck;
use App\Models\User;
use App\Services\PressRelease\CanonicalAssigner;
use App\Services\PressRelease\PressReleaseService;
use App\Services\SearchConsole\IndexStatusChecker;
use App\Support\ContentFingerprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
/**
* Doppelveröffentlichung (Anhang 2, 1c.7–1c.9): identische Texte auf
* beiden Portalen bekommen dieselbe Prüfsumme, die zweite Fassung verweist
* per rel=canonical auf die erste (Kategorie entscheidet), Sitemaps nennen
* nur die Erstquelle, und die Quellenauskunft bleibt hinter E8.
*/
function duplicatePair(string $categorySlug = 'it', array $a = [], array $b = []): array
{
$category = Category::factory()->create(['portal' => Portal::Both]);
$category->translations()->create(['locale' => 'de', 'name' => ucfirst($categorySlug), 'slug' => $categorySlug]);
$company = Company::factory()->create(['portal' => Portal::Both, 'name' => 'Doppel GmbH']);
$text = '<p>Die Doppel GmbH eröffnet einen neuen Standort.</p>';
$first = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)
->create(array_merge(['slug' => 'doppel-standort', 'title' => 'Neuer Standort', 'text' => $text, 'published_at' => now()->subHours(2)], $a));
$second = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->for($category)->for($company)
->create(array_merge(['slug' => 'doppel-standort', 'title' => 'Neuer Standort', 'text' => "<p>Die Doppel GmbH eröffnet einen\nneuen Standort.</p>", 'published_at' => now()->subHour()], $b));
return [$first, $second, $category, $company];
}
test('the fingerprint ignores markup, whitespace and case and is set on save', function () {
expect(ContentFingerprint::hash('Titel', '<p>Ein Text</p>'))->toBe(ContentFingerprint::hash('titel', "ein\ntext"))
->and(ContentFingerprint::hash('Titel', 'Ein Text'))->not->toBe(ContentFingerprint::hash('Titel', 'Ein anderer Text'));
$release = PressRelease::factory()->create(['title' => 'Hallo', 'text' => 'Welt']);
expect($release->content_hash)->toBe(ContentFingerprint::hash('Hallo', 'Welt'));
$release->update(['text' => 'Neue Welt']);
expect($release->fresh()->content_hash)->toBe(ContentFingerprint::hash('Hallo', 'Neue Welt'));
});
test('the backfill command fills missing fingerprints without model events', function () {
/** @var TestCase $this */
$release = PressRelease::factory()->create(['title' => 'Alt', 'text' => 'Bestand']);
DB::table('press_releases')->where('id', $release->id)->update(['content_hash' => null]);
$this->artisan(HashPressReleaseContent::class)->assertSuccessful();
expect($release->fresh()->content_hash)->toBe(ContentFingerprint::hash('Alt', 'Bestand'));
});
test('the category mapping decides which version is the source, otherwise the first published', function () {
config()->set('canonical.enabled', true);
config()->set('canonical.portal_by_category', ['it' => 'businessportal24']);
config()->set('canonical.fallback', 'first');
[$pe, $b24] = duplicatePair('it');
$assigner = app(CanonicalAssigner::class);
expect($pe->content_hash)->toBe($b24->content_hash)
->and($assigner->resolveCanonical($pe, $b24)->is($b24))->toBeTrue();
[$peOther, $b24Other] = duplicatePair('reisen', ['slug' => 'reise-a'], ['slug' => 'reise-b']);
expect($assigner->resolveCanonical($peOther, $b24Other)->is($peOther))->toBeTrue();
});
test('assigning the backlog links the duplicate to the source and the sitemaps list only the source', function () {
/** @var TestCase $this */
config()->set('canonical.enabled', true);
config()->set('canonical.portal_by_category', ['it' => 'businessportal24']);
[$pe, $b24] = duplicatePair('it');
// Vor der Zuordnung nennt jedes Portal seine eigene Fassung — und cached die Sitemap eine Stunde.
$this->get('https://presseecho.test/sitemap.xml')->assertSuccessful()
->assertSee('https://presseecho.test/de/press-release/doppel-standort', false);
$this->artisan(AssignCanonicalPressReleases::class, ['--dry-run' => true])
->expectsOutputToContain('[Dry-Run] 1 Paare, 1 würden geändert.')
->assertSuccessful();
expect($b24->fresh()->canonical_press_release_id)->toBeNull();
$this->artisan(AssignCanonicalPressReleases::class)
->expectsOutputToContain('1 Paare, 1 geändert.')
->expectsOutputToContain('Seiten- und Sitemap-Caches geleert.')
->assertSuccessful();
expect($pe->fresh()->canonical_press_release_id)->toBe($b24->id)
->and($b24->fresh()->canonical_press_release_id)->toBeNull();
// Zweiter Lauf ändert nichts mehr.
$this->artisan(AssignCanonicalPressReleases::class)
->expectsOutputToContain('1 Paare, 0 geändert.')
->doesntExpectOutputToContain('Caches geleert')
->assertSuccessful();
// Detailseite: die Kopie zeigt per rel=canonical auf die Erstquelle, die Erstquelle auf sich selbst.
$this->get('https://presseecho.test/de/press-release/doppel-standort')->assertSuccessful()
->assertSee('<link rel="canonical" href="https://businessportal24.test/de/press-release/doppel-standort">', false)
->assertSee('"mainEntityOfPage":{"@type":"WebPage","@id":"https://businessportal24.test/de/press-release/doppel-standort"}', false);
$this->get('https://businessportal24.test/de/press-release/doppel-standort')->assertSuccessful()
->assertSee('<link rel="canonical" href="https://businessportal24.test/de/press-release/doppel-standort">', false);
// Sitemaps: nur die Erstquelle — der Lauf hat den 1-h-Cache von oben geleert.
$this->get('https://presseecho.test/sitemap.xml')->assertSuccessful()
->assertDontSee('https://presseecho.test/de/press-release/doppel-standort', false);
$this->get('https://businessportal24.test/sitemap.xml')->assertSuccessful()
->assertSee('https://businessportal24.test/de/press-release/doppel-standort', false);
$this->get('https://presseecho.test/sitemap-news.xml')->assertSuccessful()
->assertDontSee('doppel-standort', false);
$this->get('https://businessportal24.test/sitemap-news.xml')->assertSuccessful()
->assertSee('doppel-standort', false);
$this->artisan(ReportDuplicatePressReleases::class)
->expectsOutputToContain('Paare gleichen Inhalts auf beiden Portalen: 1 (2 URLs)')
->expectsOutputToContain('davon mit kanonischer Zuordnung: 1')
->assertSuccessful();
});
test('without the flag every version stays its own canonical and nothing is assigned', function () {
/** @var TestCase $this */
config()->set('canonical.enabled', false);
[$pe, $b24] = duplicatePair('it');
$this->artisan(AssignCanonicalPressReleases::class)->expectsOutputToContain('Zuordnung ist aus')->assertSuccessful();
expect($pe->fresh()->canonical_press_release_id)->toBeNull();
$this->get('https://presseecho.test/de/press-release/doppel-standort')->assertSuccessful()
->assertSee('<link rel="canonical" href="https://presseecho.test/de/press-release/doppel-standort">', false);
$this->get('https://presseecho.test/sitemap.xml')->assertSuccessful()
->assertSee('https://presseecho.test/de/press-release/doppel-standort', false);
});
test('a release with portal both is canonical on the category portal only', function () {
/** @var TestCase $this */
config()->set('canonical.enabled', true);
config()->set('canonical.portal_by_category', ['handel' => 'presseecho']);
$category = Category::factory()->create(['portal' => Portal::Both]);
$category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']);
$release = PressRelease::factory()->published()->forPortal(Portal::Both)->for($category)
->for(Company::factory()->create(['portal' => Portal::Both]))
->create(['slug' => 'beide-portale', 'published_at' => now()->subHour()]);
$this->get('https://businessportal24.test/de/press-release/beide-portale')->assertSuccessful()
->assertSee('<link rel="canonical" href="https://presseecho.test/de/press-release/beide-portale">', false);
$this->get('https://businessportal24.test/sitemap.xml')->assertSuccessful()->assertDontSee('beide-portale', false);
$this->get('https://presseecho.test/sitemap.xml')->assertSuccessful()->assertSee('beide-portale', false);
expect(app(CanonicalAssigner::class)->isCanonicalOn($release, Portal::Presseecho))->toBeTrue()
->and(app(CanonicalAssigner::class)->isCanonicalOn($release, Portal::Businessportal24))->toBeFalse();
});
test('publishing the second version assigns the pair immediately', function () {
/** @var TestCase $this */
Mail::fake();
config()->set('canonical.enabled', true);
config()->set('canonical.portal_by_category', ['it' => 'presseecho']);
[$pe, $b24] = duplicatePair('it');
DB::table('press_releases')->where('id', $b24->id)->update(['status' => PressReleaseStatus::Review->value, 'published_at' => null]);
$b24 = $b24->fresh();
$b24->user->update(['is_active' => true]);
app(PressReleaseService::class)->publish($b24, 'admin');
expect($b24->fresh()->canonical_press_release_id)->toBe($pe->id)
->and($pe->fresh()->canonical_press_release_id)->toBeNull();
});
test('the source verdict tells own, other-own and foreign canonicals apart and shows only behind the flag', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->businessportal24()->create();
$customer->companies()->attach($company->id, ['role' => 'member']);
$this->actingAs($customer);
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'slug' => 'quelle-pm', 'published_at' => now()->subDay()]);
$url = 'https://businessportal24.test/de/press-release/quelle-pm';
$checker = app(IndexStatusChecker::class);
$self = PressReleaseIndexCheck::factory()->create(['press_release_id' => $release->id, 'portal' => Portal::Businessportal24, 'url' => $url, 'google_canonical' => $url, 'user_canonical' => $url]);
$ownOther = PressReleaseIndexCheck::factory()->make(['url' => $url, 'google_canonical' => 'https://presseecho.test/de/press-release/quelle-pm']);
$foreign = PressReleaseIndexCheck::factory()->make(['url' => $url, 'google_canonical' => 'https://www.fremdes-portal.example/pm/quelle']);
$none = PressReleaseIndexCheck::factory()->make(['url' => $url, 'google_canonical' => null]);
expect($checker->sourceVerdict($self)['kind'])->toBe('self')
->and($checker->sourceVerdict($ownOther))->toMatchArray(['kind' => 'own_other', 'host' => 'presseecho.test'])
->and($checker->sourceVerdict($foreign))->toMatchArray(['kind' => 'foreign', 'host' => 'www.fremdes-portal.example'])
->and($checker->sourceVerdict($none)['kind'])->toBe('unknown');
config()->set('search_console.customer_visible', false);
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])->assertDontSeeText('maßgebliche Quelle');
config()->set('search_console.customer_visible', true);
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])
->assertSeeText('Google führt diese Adresse als maßgebliche Quelle.')
->assertSeeText('warum, ist nicht bekannt');
});
test('portal and auth layouts carry noindex and the help page explains the single source', function () {
/** @var TestCase $this */
$this->get('https://pressekonto.test/login')->assertSuccessful()->assertSee('<meta name="robots" content="noindex, nofollow" />', false);
config()->set('canonical.enabled', false);
$this->get('https://presseecho.test/de/gute-pressemitteilung')->assertSuccessful()
->assertSee('Warum eine Meldung mehrfach im Netz steht und nur einmal in den Suchergebnissen')
->assertSee('nicht zwei Fundstellen in der Suche')
->assertDontSee('verweist bei uns die zweite Fassung auf die erste');
config()->set('canonical.enabled', true);
$this->get('https://presseecho.test/de/gute-pressemitteilung')->assertSuccessful()
->assertSee('verweist bei uns die zweite Fassung auf die erste');
});
test('copies are re-evaluated when their source is depublished, republished, gone or deleted', 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);
$service = app(PressReleaseService::class);
$assigner->assignFor($pe);
expect($pe->fresh()->canonical_press_release_id)->toBe($b24->id);
// Erstquelle depubliziert: die Kopie wird selbst Erstquelle — Self-Canonical UND Sitemap.
$service->depublish($b24->fresh());
expect($pe->fresh()->canonical_press_release_id)->toBeNull()
->and($assigner->isCanonicalOn($pe->fresh(), Portal::Presseecho))->toBeTrue();
$this->get('https://presseecho.test/de/press-release/doppel-standort')->assertSuccessful()
->assertSee('<link rel="canonical" href="https://presseecho.test/de/press-release/doppel-standort">', false);
$this->get('https://presseecho.test/sitemap.xml')->assertSuccessful()
->assertSee('https://presseecho.test/de/press-release/doppel-standort', false);
// Erstquelle per Admin-Statuswechsel wieder veröffentlicht: Paar erneut zugeordnet.
$service->changeStatusFromAdmin($b24->fresh(), PressReleaseStatus::Published);
expect($pe->fresh()->canonical_press_release_id)->toBe($b24->id);
// Admin nimmt die Erstquelle zurück: Kopie wieder frei.
$service->changeStatusFromAdmin($b24->fresh(), PressReleaseStatus::Draft, 'Zurückgezogen');
expect($pe->fresh()->canonical_press_release_id)->toBeNull();
$service->changeStatusFromAdmin($b24->fresh(), PressReleaseStatus::Published);
expect($pe->fresh()->canonical_press_release_id)->toBe($b24->id);
// 410 Gone.
$service->markGone($b24->fresh());
expect($pe->fresh()->canonical_press_release_id)->toBeNull();
$service->restoreFromGone($b24->fresh());
$service->changeStatusFromAdmin($b24->fresh(), PressReleaseStatus::Published);
expect($pe->fresh()->canonical_press_release_id)->toBe($b24->id);
// Entfernung durch den Admin.
$service->deleteFromAdmin($b24->fresh());
expect($pe->fresh()->canonical_press_release_id)->toBeNull();
});
test('a soft-deleted source is no canonical target even while the copy still points at it', function () {
config()->set('canonical.enabled', true);
config()->set('canonical.portal_by_category', ['it' => 'businessportal24']);
[$pe, $b24] = duplicatePair('it');
app(CanonicalAssigner::class)->assignFor($pe);
// Direkt gelöscht, ohne Service-Hook — der Verweis bleibt als Leiche stehen.
$b24->delete();
$pe = $pe->fresh();
expect($pe->canonical_press_release_id)->toBe($b24->id)
->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);
});