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(); });