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 $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' => '

Gleicher Text

']); $copy = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category) ->create(['company_id' => $loser->id, 'title' => 'Neuer Standort', 'text' => '

Gleicher Text

']); // 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); });