presseportale/tests/Feature/CanonicalAssignmentTest.php
Kevin Adametz 4edb915a9a web: Doppelveröffentlichung — Prüfsumme, kanonische Zuordnung (aus bis E9), Quellenauskunft, noindex im Portal (Anhang 2)
Über die API kommt praktisch jede Meldung zweimal, je Portal eine Zeile
mit gleichem Text: 9.092 Paare, 53 % des Archivs, bisher jede Fassung
self-canonical und in beiden Sitemaps — Google wählte.

1c.7: press_releases.content_hash (SHA-256 über Titel + Text ohne Markup),
gesetzt beim Speichern, Bestand per press-releases:hash-content
(Query-Builder, Blöcke). Migration in zwei Statements — ADD COLUMN INSTANT,
ADD INDEX INPLACE, kein Rebuild. press-releases:duplicates-report zählt
Paare je Hauptkategorie.

1c.8: canonical_press_release_id + CanonicalAssigner. Erstquelle folgt der
Kategorie (config/canonical.php, Vorschlag zu E9), sonst die zuerst
veröffentlichte Fassung. Zuordnung bei jeder Veröffentlichung und über den
Bestand (press-releases:assign-canonicals). Die Kopie trägt rel=canonical
und mainEntityOfPage auf die Erstquelle; Sitemap und News-Sitemap nennen
nur sie; Leser sehen weiterhin beide. Alles hinter
CANONICAL_ASSIGNMENT_ENABLED (false), bis E9 entschieden ist.

1c.9: user_canonical in den Index-Prüfungen, sourceVerdict() unterscheidet
eigene Adresse / andere eigene Fassung / fremdes Portal; Anzeige im Block
„Google-Index" hinter GSC_CUSTOMER_VISIBLE, ohne Aussage über das Warum.

Dazu: Abschnitt zur einen Quelle auf /gute-pressemitteilung (Verteiler →
viele Kopien, eigene Website zuerst, keine Garantie), noindex in Admin-,
Kunden- und Auth-Layouts, UmamiSnippetTest von der lokalen .env gelöst.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:09:16 +00:00

217 lines
12 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');
$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.')->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.')->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.
$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');
});