- 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>
362 lines
19 KiB
PHP
362 lines
19 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');
|
||
|
||
// 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);
|
||
});
|