Ü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>
217 lines
12 KiB
PHP
217 lines
12 KiB
PHP
<?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');
|
||
});
|