presseportale/tests/Feature/CompanyMergeTest.php
Kevin Adametz 2aebb9d27e companies: Zusammenführung im Bestand, rückabwickelbar (Roadmap 1a.3)
companies:merge führt Firmen mit derselben Firmen-E-Mail zu einem Newsroom
zusammen — nur über Adressen, die 1a.2 als Dublette oder Namensvariante
klassifiziert hat. Es bleibt die Firma mit den meisten jemals
veröffentlichten Meldungen; Meldungen, Kontakte, Nutzer- und
Zahlungsverknüpfungen wandern per company_id, jede Bewegung steht in
company_merge_moves. companies:unmerge spielt sie rückwärts ab.

77 % der Gruppen sind dieselbe Firma auf beiden Portalen (Altsystem: eine
Firma je Portal). Der Überlebende wird dann `both`; die Meldungen behalten
ihr Portal. Der alte Newsroom-Slug führt per 301 zum Überlebenden.
Slug-Kollisionen auf dem anderen Portal werden übersprungen, nicht
umbenannt. Company::slugExists() kennt jetzt `both`.

Dev-DB: 14 142 Adressen, 16 194 Firmen, 13 297 Meldungen in 2 min;
Rückabwicklung in 1:15 min exakt auf den Stand davor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 08:43:46 +00:00

325 lines
16 KiB
PHP

<?php
use App\Enums\CompanyAddressClass;
use App\Enums\CompanyMergeSubject;
use App\Enums\Portal;
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 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();
});