presseportale/tests/Feature/CanonicalAssignmentTest.php
Kevin Adametz eb658020e5 press-releases: assign-canonicals leert Seiten- und Sitemap-Caches; dev: Live-Lauf 21.09.2026 protokolliert (9.221 Paare)
Der Nachlauf über den Bestand änderte rel=canonical und die Sitemap-Auswahl,
ließ aber gecachte Meldungsseiten (1 h stale) und Sitemap-Caches (1 h)
stehen. Nach einem Lauf mit Änderungen wird WebCaches::forgetPublicPages()
aufgerufen; der Test prüft das über den vorher gecachten Sitemap-Stand.

Protokoll Teil 11: Nachtrag Live-Deploy (hash-content 179.619,
duplicates-report 9.221 Paare / 1.468 seit 07/2026, assign-canonicals
9.223/9.221, basiswert-0.1.json auf dem Server), Anhang 2 1c.7/1c.8 mit
Live-Zahlen, Basiswerte 0.1 mit Hinweis auf die noch zu holende Datei.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 13:05:43 +00:00

227 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');
// 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');
});