presseportale/tests/Feature/CanonicalAssignmentTest.php
Kevin Adametz 2cde7284a1 livewire: Volt entfernt — Komponenten als Livewire-4-Single-File-Components, Route::livewire(), Livewire::test(); livewire/volt deinstalliert
- 83 Komponenten: Livewire\Volt\Component → Livewire\Component
- 73 Routen (admin, auth, customer): Volt::route() → Route::livewire()
- 88 Testdateien: Volt::test/actingAs → Livewire::test/actingAs
- VoltServiceProvider gelöscht; composer remove livewire/volt
- config/livewire.php auf v4-Schlüssel (component_layout, component_placeholder,
  component_locations, make_command: SFC ohne ⚡). Der alte Schlüssel layout war
  wirkungslos; alle gerouteten Komponenten setzen ihr Layout selbst.
- CLAUDE.md, AGENTS.md, boost.json, .cursorrules, Skill volt-development
- Roadmap: erledigt; Larastan erfasst SFCs weiterhin nicht (Blade-Dateien)

Suite 2.325 grün, einer übersprungen; Larastan und Pint ohne Meldung;
route:cache und view:cache laufen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:59:36 +00:00

362 lines
19 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\Livewire;
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);
Livewire::test('customer.press-releases.show', ['id' => $release->id])->assertDontSeeText('maßgebliche Quelle');
config()->set('search_console.customer_visible', true);
Livewire::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);
});