web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)

- 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>
This commit is contained in:
Kevin Adametz 2026-10-02 13:09:00 +00:00
parent 1d417f87f7
commit c2e0b85242
48 changed files with 1676 additions and 134 deletions

View file

@ -3,6 +3,7 @@
use App\Enums\CompanyAddressClass;
use App\Enums\CompanyMergeSubject;
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\CompanyEmailGroup;
use App\Models\CompanyMerge;
@ -12,6 +13,7 @@ use App\Models\User;
use App\Services\Company\CompanyMergePlan;
use App\Services\Company\CompanyMergeService;
use App\Services\Company\CompanyMergeSkip;
use App\Services\Company\MergedNewsroomLookup;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
@ -345,3 +347,118 @@ test('the merge list shows survivor, merged companies and the URLs to check', fu
->expectsOutputToContain('muster-gmbh')
->assertSuccessful();
});
test('merge and unmerge leave updated_at of the moved releases untouched', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
mergeMember('info@muster.de', Portal::Presseecho, 3);
$loser = mergeMember('info@muster.de', Portal::Businessportal24, 1);
$release = PressRelease::withoutGlobalScopes()->where('company_id', $loser->id)->sole();
$before = $release->updated_at->toDateTimeString();
$this->travel(3)->days();
$service = app(CompanyMergeService::class);
$merge = $service->merge(planFor('info@muster.de'), 'b');
expect(PressRelease::withoutGlobalScopes()->find($release->id)->updated_at->toDateTimeString())->toBe($before);
$service->revert($merge);
expect(PressRelease::withoutGlobalScopes()->find($release->id))
->company_id->toBe($loser->id)
->updated_at->toDateTimeString()->toBe($before);
});
test('unmerge keeps a field the customer changed after the merge', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 3, ['website' => null, 'boilerplate' => null, 'phone' => null]);
mergeMember('info@muster.de', Portal::Businessportal24, 0, ['website' => 'https://muster.de', 'boilerplate' => 'Über Muster', 'phone' => '030 2']);
$service = app(CompanyMergeService::class);
$merge = $service->merge(planFor('info@muster.de'), 'b');
expect($merge->moves()->where('field', 'boilerplate')->value('merged_value'))->toBe('Über Muster');
// Der Kunde beansprucht den Newsroom und schreibt den Text neu.
$survivor->refresh()->update(['boilerplate' => 'Unser eigener Text']);
$service->revert($merge);
$survivor->refresh();
expect($survivor->boilerplate)->toBe('Unser eigener Text')
->and($survivor->website)->toBeNull()
->and($survivor->phone)->toBeNull();
});
test('unmerge of an older merge without stored fill value compares against the merged company', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 3, ['website' => null, 'boilerplate' => null]);
mergeMember('info@muster.de', Portal::Businessportal24, 0, ['website' => 'https://muster.de', 'boilerplate' => 'Über Muster']);
$service = app(CompanyMergeService::class);
$merge = $service->merge(planFor('info@muster.de'), 'b');
// Stand vor der Migration: kein Füllwert in der Move-Zeile.
$merge->moves()->update(['merged_value' => null]);
$survivor->refresh()->update(['boilerplate' => 'Unser eigener Text']);
$service->revert($merge);
$survivor->refresh();
expect($survivor->boilerplate)->toBe('Unser eigener Text')
->and($survivor->website)->toBeNull();
});
test('merge and unmerge re-evaluate the canonical assignment of the moved releases', function () {
/** @var TestCase $this */
config()->set('canonical.enabled', true);
config()->set('canonical.portal_by_category', ['it' => 'businessportal24']);
$category = Category::factory()->create(['portal' => Portal::Both]);
$category->translations()->create(['locale' => 'de', 'name' => 'IT', 'slug' => 'it']);
mergeGroup('info@muster.de');
$survivor = mergeMember('info@muster.de', Portal::Businessportal24, 2);
$loser = mergeMember('info@muster.de', Portal::Presseecho);
$source = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->for($category)
->create(['company_id' => $survivor->id, 'title' => 'Neuer Standort', 'text' => '<p>Gleicher Text</p>']);
$copy = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)
->create(['company_id' => $loser->id, 'title' => 'Neuer Standort', 'text' => '<p>Gleicher Text</p>']);
// Verschiedene Firmen: kein Paar.
expect($copy->fresh()->canonical_press_release_id)->toBeNull();
$service = app(CompanyMergeService::class);
$merge = $service->merge(planFor('info@muster.de'), 'b');
expect($copy->fresh()->canonical_press_release_id)->toBe($source->id)
->and($source->fresh()->canonical_press_release_id)->toBeNull();
$service->revert($merge);
// Wieder verschiedene Firmen: der Verweis auf die fremde Meldung fällt.
expect($copy->fresh()->canonical_press_release_id)->toBeNull()
->and($source->fresh()->canonical_press_release_id)->toBeNull();
});
test('an old newsroom slug follows a chain of merges to the current survivor', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$middle = mergeMember('info@muster.de', Portal::Presseecho, 2, ['slug' => 'muster-mitte']);
mergeMember('info@muster.de', Portal::Presseecho, 1, ['slug' => 'muster-alt']);
$service = app(CompanyMergeService::class);
$service->merge(planFor('info@muster.de'), 'b1');
// Später: Die Mitte geht ihrerseits in einer weiteren Firma auf.
$final = mergeMember('info@muster.de', Portal::Presseecho, 5, ['slug' => 'muster-neu']);
PressRelease::withoutGlobalScopes()->where('company_id', $final->id)->update(['published_at' => now()->subDay()]);
$service->merge(planFor('info@muster.de'), 'b2');
expect(Company::withTrashed()->find($middle->id)->trashed())->toBeTrue()
->and(app(MergedNewsroomLookup::class)->survivorFor(['presseecho', 'both'], 'muster-alt')?->id)->toBe($final->id);
$this->get('https://presseecho.test/de/newsroom/muster-alt')
->assertRedirect('https://presseecho.test/de/newsroom/muster-neu')
->assertStatus(301);
});