presseportale/tests/Feature/CompanyMergeTest.php
Kevin Adametz d6e49c11c3 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>
2026-10-02 13:09:00 +00:00

464 lines
22 KiB
PHP

<?php
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;
use App\Models\Contact;
use App\Models\PressRelease;
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;
/**
* Zusammenführung im Bestand (Roadmap 1a.3): rückabwickelbar, mit
* gespeicherter Ursprungszuordnung. Entschieden am 15.09.2026: Es bleibt die
* Firma mit den meisten Meldungen, alles wandert per company_id, die
* wegfallende Firma wird soft-deleted und ihr Slug führt per 301 weiter.
*/
function mergeGroup(string $email, CompanyAddressClass $class = CompanyAddressClass::Duplicate, ?CompanyAddressClass $manual = null): CompanyEmailGroup
{
return CompanyEmailGroup::query()->create([
'email' => Company::emailKey($email),
'classification' => $class,
'manual_classification' => $manual,
'companies_count' => 2,
'distinct_names' => 1,
'published_companies_count' => 1,
'threshold' => 3,
'classified_at' => now(),
]);
}
/**
* @param array<string, mixed> $attributes
*/
function mergeMember(string $email, Portal $portal, int $published = 0, array $attributes = []): Company
{
$company = Company::factory()->create(['email' => $email, 'portal' => $portal->value, 'name' => 'Muster GmbH'] + $attributes);
PressRelease::factory()->published()->forPortal($portal)->count($published)->create(['company_id' => $company->id]);
return $company;
}
function planFor(string $email): CompanyMergePlan|CompanyMergeSkip
{
return app(CompanyMergeService::class)->plan(CompanyEmailGroup::forEmail($email));
}
test('the company with more publications survives and becomes both when halves lie on different portals', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$pe = mergeMember('info@muster.de', Portal::Presseecho, 2);
$b24 = mergeMember('info@muster.de', Portal::Businessportal24, 5);
$plan = planFor('info@muster.de');
expect($plan)->toBeInstanceOf(CompanyMergePlan::class)
->and($plan->survivor->is($b24))->toBeTrue()
->and($plan->targetPortal)->toBe(Portal::Both)
->and($plan->pressReleases)->toBe(2);
$merge = app(CompanyMergeService::class)->merge($plan, 'test-batch');
// Die Meldungen behalten ihr Portal — nur die Firma wird portalübergreifend.
expect($b24->fresh()->portal)->toBe(Portal::Both)
->and(PressRelease::withoutGlobalScopes()->where('company_id', $b24->id)->count())->toBe(7)
->and(PressRelease::withoutGlobalScopes()->where('company_id', $b24->id)->where('portal', 'presseecho')->count())->toBe(2)
->and(Company::withTrashed()->find($pe->id)->trashed())->toBeTrue()
->and($merge->moved_press_releases_count)->toBe(2)
->and($merge->moves()->where('subject', CompanyMergeSubject::PressRelease->value)->count())->toBe(2)
->and($merge->moves()->where('subject', CompanyMergeSubject::Company->value)->value('subject_id'))->toBe($pe->id);
});
test('on a tie the older company survives', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$older = mergeMember('info@muster.de', Portal::Presseecho, 1);
mergeMember('info@muster.de', Portal::Presseecho, 1);
$plan = planFor('info@muster.de');
expect($plan)->toBeInstanceOf(CompanyMergePlan::class)
->and($plan->survivor->is($older))->toBeTrue()
->and($plan->targetPortal)->toBe(Portal::Presseecho);
});
test('releases taken offline in the legacy system count for the survivor choice', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$fresh = mergeMember('info@muster.de', Portal::Presseecho, 1);
$legacy = mergeMember('info@muster.de', Portal::Businessportal24);
PressRelease::factory()->count(3)->create(['company_id' => $legacy->id, 'status' => 'rejected', 'hits' => 50]);
expect(planFor('info@muster.de')->survivor->is($legacy))->toBeTrue();
});
test('empty profile fields are filled from the merged company, filled ones stay', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 3, ['website' => null, 'phone' => '030 1', 'logo_path' => null]);
mergeMember('info@muster.de', Portal::Businessportal24, 0, ['website' => 'https://muster.de', 'phone' => '030 2', 'logo_path' => 'logos/muster.png']);
$merge = app(CompanyMergeService::class)->merge(planFor('info@muster.de'), 'b');
$survivor->refresh();
expect($survivor->website)->toBe('https://muster.de')
->and($survivor->logo_path)->toBe('logos/muster.png')
->and($survivor->phone)->toBe('030 1')
->and($merge->moves()->where('subject', CompanyMergeSubject::Field->value)->pluck('field')->sort()->values()->all())->toBe(['logo_path', 'website']);
});
test('user links become the union without duplicates and contacts move along', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 1);
$loser = mergeMember('info@muster.de', Portal::Businessportal24);
$shared = User::factory()->create();
$only = User::factory()->create();
DB::table('company_user')->insert([
['company_id' => $survivor->id, 'user_id' => $shared->id, 'role' => 'owner'],
['company_id' => $loser->id, 'user_id' => $shared->id, 'role' => 'member'],
['company_id' => $loser->id, 'user_id' => $only->id, 'role' => 'member'],
]);
$contact = Contact::factory()->create(['company_id' => $loser->id]);
$merge = app(CompanyMergeService::class)->merge(planFor('info@muster.de'), 'b');
expect(DB::table('company_user')->where('company_id', $survivor->id)->pluck('user_id')->sort()->values()->all())->toBe(collect([$shared->id, $only->id])->sort()->values()->all())
->and(DB::table('company_user')->where('company_id', $loser->id)->count())->toBe(0)
->and($contact->fresh()->company_id)->toBe($survivor->id)
->and($merge->moves()->where('subject', CompanyMergeSubject::CompanyUser->value)->where('dropped', true)->value('subject_id'))->toBe($shared->id);
});
test('unmerge restores exactly what was moved and leaves what came later', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 1, ['website' => null]);
$loser = mergeMember('info@muster.de', Portal::Businessportal24, 2, ['website' => 'https://muster.de']);
$loserReleases = PressRelease::withoutGlobalScopes()->where('company_id', $loser->id)->pluck('id');
$shared = User::factory()->create();
DB::table('company_user')->insert([
['company_id' => $survivor->id, 'user_id' => $shared->id, 'role' => 'owner'],
['company_id' => $loser->id, 'user_id' => $shared->id, 'role' => 'member'],
]);
$service = app(CompanyMergeService::class);
$merge = $service->merge($service->plan(CompanyEmailGroup::forEmail('info@muster.de')), 'b');
// Nach der Zusammenführung entsteht eine neue Meldung beim Überlebenden.
$later = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->create(['company_id' => $survivor->id]);
$service->revert($merge);
$survivor->refresh();
expect($survivor->portal)->toBe(Portal::Presseecho)
->and($survivor->website)->toBeNull()
->and(Company::withoutGlobalScopes()->find($loser->id)->trashed())->toBeFalse()
->and(PressRelease::withoutGlobalScopes()->whereIn('id', $loserReleases)->pluck('company_id')->unique()->all())->toBe([$loser->id])
->and($later->fresh()->company_id)->toBe($survivor->id)
->and(DB::table('company_user')->where('company_id', $loser->id)->where('user_id', $shared->id)->value('role'))->toBe('member')
->and(DB::table('company_user')->where('company_id', $survivor->id)->count())->toBe(1)
->and($merge->fresh()->isReverted())->toBeTrue();
// Ein zweites Mal tut nichts.
$service->revert($merge->fresh());
expect(Company::withoutGlobalScopes()->find($loser->id)->trashed())->toBeFalse();
});
test('a slug taken by a foreign company on the other portal skips the address', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
// Beide Hälften tragen verschiedene Slugs, und jeder ist auf dem jeweils
// anderen Portal von einer fremden Firma belegt — keiner kann `both` werden.
mergeMember('info@muster.de', Portal::Presseecho, 3, ['slug' => 'muster-a']);
mergeMember('info@muster.de', Portal::Businessportal24, 1, ['slug' => 'muster-b']);
Company::factory()->create(['email' => 'anders@fremd.de', 'portal' => 'businessportal24', 'slug' => 'muster-a']);
Company::factory()->create(['email' => 'anders@fremd.de', 'portal' => 'presseecho', 'slug' => 'muster-b']);
$plan = planFor('info@muster.de');
expect($plan)->toBeInstanceOf(CompanyMergeSkip::class)
->and($plan->reason)->toBe(CompanyMergeSkip::SlugCollision)
->and(Company::withoutGlobalScopes()->where('email', 'info@muster.de')->count())->toBe(2);
});
test('when the best candidate collides but another does not, the other survives', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
mergeMember('info@muster.de', Portal::Presseecho, 3, ['slug' => 'muster']);
$free = mergeMember('info@muster.de', Portal::Businessportal24, 1, ['slug' => 'muster-gmbh']);
// „muster" ist auf B24 fremd belegt → die PE-Hälfte kann nicht `both` werden,
// die B24-Hälfte mit „muster-gmbh" schon.
Company::factory()->create(['email' => 'anders@fremd.de', 'portal' => 'businessportal24', 'slug' => 'muster']);
$plan = planFor('info@muster.de');
expect($plan)->toBeInstanceOf(CompanyMergePlan::class)
->and($plan->survivor->is($free))->toBeTrue();
});
test('only mergeable classes are touched and a manual decision overrides', function () {
/** @var TestCase $this */
mergeGroup('agentur@sammel.de', CompanyAddressClass::CollectionAddress);
mergeGroup('doch@agentur.de', CompanyAddressClass::CollectionAddress, manual: CompanyAddressClass::Duplicate);
mergeGroup('nicht@dublette.de', CompanyAddressClass::Duplicate, manual: CompanyAddressClass::CollectionAddress);
foreach (['agentur@sammel.de', 'doch@agentur.de', 'nicht@dublette.de'] as $email) {
mergeMember($email, Portal::Presseecho, 1);
mergeMember($email, Portal::Businessportal24);
}
$this->artisan('companies:merge')->assertSuccessful();
expect(CompanyMerge::query()->pluck('email')->all())->toBe(['doch@agentur.de'])
->and(Company::withoutGlobalScopes()->whereNull('deleted_at')->where('email', 'agentur@sammel.de')->count())->toBe(2)
->and(Company::withoutGlobalScopes()->whereNull('deleted_at')->where('email', 'nicht@dublette.de')->count())->toBe(2);
});
test('companies removed with 410 stay out of the merge', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
mergeMember('info@muster.de', Portal::Presseecho, 1);
$gone = mergeMember('info@muster.de', Portal::Businessportal24, 4, ['gone_at' => now()]);
$plan = planFor('info@muster.de');
// Nur eine lebende Firma übrig → nichts zu tun; die entfernte bleibt entfernt.
expect($plan)->toBeInstanceOf(CompanyMergeSkip::class)
->and($plan->reason)->toBe(CompanyMergeSkip::NothingToMerge)
->and($gone->fresh()->gone_at)->not->toBeNull();
});
test('dry run and limit write nothing and count correctly', function () {
/** @var TestCase $this */
foreach (['a@muster.de', 'b@muster.de', 'c@muster.de'] as $email) {
mergeGroup($email);
mergeMember($email, Portal::Presseecho, 1);
mergeMember($email, Portal::Businessportal24);
}
$this->artisan('companies:merge', ['--dry-run' => true])
->expectsOutputToContain('[Dry-Run] 3 Adressen')
->assertSuccessful();
expect(CompanyMerge::query()->count())->toBe(0)
->and(Company::withoutGlobalScopes()->whereNotNull('deleted_at')->count())->toBe(0);
$this->artisan('companies:merge', ['--limit' => 2])->assertSuccessful();
expect(CompanyMerge::query()->count())->toBe(2)
->and(CompanyMerge::query()->distinct()->count('batch'))->toBe(1);
// Der zweite Lauf holt den Rest — die erledigten Adressen zählen nicht
// gegen das Limit.
$this->artisan('companies:merge', ['--limit' => 2])->assertSuccessful();
expect(CompanyMerge::query()->count())->toBe(3);
});
test('unmerge by batch reverts every merge of that run', function () {
/** @var TestCase $this */
foreach (['a@muster.de', 'b@muster.de'] as $email) {
mergeGroup($email);
mergeMember($email, Portal::Presseecho, 1);
mergeMember($email, Portal::Businessportal24);
}
$this->artisan('companies:merge')->assertSuccessful();
$batch = CompanyMerge::query()->value('batch');
$this->artisan('companies:unmerge', ['--batch' => $batch])
->expectsOutputToContain('2 Vorgänge rückabgewickelt')
->assertSuccessful();
expect(Company::withoutGlobalScopes()->whereNotNull('deleted_at')->count())->toBe(0)
->and(CompanyMerge::query()->whereNull('reverted_at')->count())->toBe(0)
->and(Company::withoutGlobalScopes()->where('portal', 'both')->count())->toBe(0);
});
test('the old newsroom slug redirects permanently to the survivor', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
$survivor = mergeMember('info@muster.de', Portal::Businessportal24, 2, ['slug' => 'muster-gmbh']);
$loser = mergeMember('info@muster.de', Portal::Presseecho, 1, ['slug' => 'muster']);
$release = PressRelease::withoutGlobalScopes()->where('company_id', $loser->id)->first();
$release->update(['published_at' => now()->subDay()]);
$this->get('https://presseecho.test/de/newsroom/muster')->assertOk();
$service = app(CompanyMergeService::class);
$merge = $service->merge($service->plan(CompanyEmailGroup::forEmail('info@muster.de')), 'b');
// Alte URL → 301 auf den Überlebenden, der auf diesem Portal über `both`
// erreichbar ist und die Meldung dieses Portals zeigt.
$this->get('https://presseecho.test/de/newsroom/muster')
->assertRedirect('https://presseecho.test/de/newsroom/muster-gmbh')
->assertStatus(301);
$this->get('https://presseecho.test/de/newsroom/muster-gmbh')->assertOk()->assertSeeText($release->title);
// Nach der Rückabwicklung antwortet die alte URL wieder selbst.
$service->revert($merge);
$this->get('https://presseecho.test/de/newsroom/muster')->assertOk();
$this->get('https://presseecho.test/de/newsroom/muster-gmbh')->assertNotFound();
});
test('the same slug on both portals needs no redirect', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
mergeMember('info@muster.de', Portal::Businessportal24, 2, ['slug' => 'muster']);
$loser = mergeMember('info@muster.de', Portal::Presseecho, 1, ['slug' => 'muster']);
PressRelease::withoutGlobalScopes()->where('company_id', $loser->id)->update(['published_at' => now()->subDay()]);
$service = app(CompanyMergeService::class);
$service->merge($service->plan(CompanyEmailGroup::forEmail('info@muster.de')), 'b');
$this->get('https://presseecho.test/de/newsroom/muster')->assertOk();
$this->get('https://businessportal24.test/de/newsroom/muster')->assertOk();
});
test('the merge list shows survivor, merged companies and the URLs to check', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
mergeMember('info@muster.de', Portal::Businessportal24, 2, ['slug' => 'muster-gmbh']);
mergeMember('info@muster.de', Portal::Presseecho, 1, ['slug' => 'muster']);
$this->artisan('companies:merge')->assertSuccessful();
$batch = CompanyMerge::query()->value('batch');
Artisan::call('companies:merges', ['--batch' => $batch, '--json' => true]);
$listed = json_decode(Artisan::output(), true)[0];
expect($listed['newsroom'])->toBe(['https://presseecho.test/de/newsroom/muster-gmbh', 'https://businessportal24.test/de/newsroom/muster-gmbh'])
->and($listed['aufgegangen'][0]['alte_url'])->toBe(['https://presseecho.test/de/newsroom/muster'])
->and($listed['meldungen'])->toBe(1);
$this->artisan('companies:merges', ['--email' => 'INFO@muster.de'])
->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);
});