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 = '
Die Doppel GmbH eröffnet einen neuen Standort.
'; $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' => "Die Doppel GmbH eröffnet einen\nneuen Standort.
", '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', 'Ein Text
'))->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('', 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('', 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('', 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('', 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('', 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'); });