seed(RolesAndPermissionsSeeder::class); config()->set('billing.enforce_booking', false); config()->set('app.theme', 'portal'); $user = User::factory()->create(['is_active' => true, 'portal' => $portal]); $user->forceFill(['registration_type' => 'apiuser'])->save(); // Zuordnung zu bestehenden Firmen nur für freigeschaltete Einlieferer // (Umsetzungsaudit 29.09.2026, R-01) — die Bestandstests laufen als // freigeschalteter Einlieferer (connektar). config()->set('newsroom.api_assignment.allowed_user_ids', $allowlisted ? [$user->id] : []); Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']); return $user; } /** * @param array $overrides */ function postApiCompany(array $overrides = []): int { $response = test()->postJson('/service/users/me/companies', [ 'company' => array_merge(['name' => 'Muster GmbH', 'email' => 'info@muster.de'], $overrides), ])->assertCreated(); return (int) basename((string) $response->headers->get('Location')); } function assignmentGroup(string $email, CompanyAddressClass $class, ?CompanyAddressClass $manual = null): void { CompanyEmailGroup::query()->create([ 'email' => $email, 'classification' => $class, 'manual_classification' => $manual, 'companies_count' => 30, 'distinct_names' => 30, 'published_companies_count' => 0, 'threshold' => 3, 'classified_at' => now(), ]); } test('the same company under the same email is reused instead of created', function () { /** @var TestCase $this */ $user = assignmentApiUser(); $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); $id = postApiCompany(['email' => 'Info@Muster.de']); expect($id)->toBe($existing->id) ->and(Company::withoutGlobalScopes()->where('email', 'like', '%muster.de')->count())->toBe(1) ->and($existing->users()->whereKey($user->id)->wherePivot('role', 'member')->wherePivot('granted_via', 'api')->exists())->toBeTrue() ->and(ActivityEvent::query()->where('subject_id', $existing->id)->where('description', 'like', 'API-Firma%')->exists())->toBeTrue(); }); test('a name that differs only by legal form or a typo still matches', function () { /** @var TestCase $this */ assignmentApiUser(); $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster Unternehmensberatung', 'portal' => 'businessportal24']); expect(postApiCompany(['name' => 'Muster Unternehmensberatung GmbH & Co. KG']))->toBe($existing->id) ->and(postApiCompany(['name' => 'Muster Unternehmensbertung']))->toBe($existing->id); }); test('a different name under the same address gets its own company as before', function () { /** @var TestCase $this */ assignmentApiUser(); Company::factory()->create(['email' => 'presse@agentur.de', 'name' => 'Kunde Eins GmbH', 'portal' => 'businessportal24']); $id = postApiCompany(['name' => 'Ganz Anderer Kunde AG', 'email' => 'presse@agentur.de']); expect(Company::withoutGlobalScopes()->find($id)->name)->toBe('Ganz Anderer Kunde AG') ->and(Company::withoutGlobalScopes()->where('email', 'presse@agentur.de')->count())->toBe(2); }); test('under a never-published address nothing is ever assigned', function () { /** @var TestCase $this */ assignmentApiUser(); assignmentGroup('yahoo@yahoo.com', CompanyAddressClass::NeverPublished); $existing = Company::factory()->create(['email' => 'yahoo@yahoo.com', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); expect(postApiCompany(['email' => 'yahoo@yahoo.com']))->not->toBe($existing->id); }); test('a manual decision can turn a never-published address back into a normal one', function () { /** @var TestCase $this */ assignmentApiUser(); assignmentGroup('presse@pr4you.de', CompanyAddressClass::NeverPublished, manual: CompanyAddressClass::CollectionAddress); $existing = Company::factory()->create(['email' => 'presse@pr4you.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); expect(postApiCompany(['email' => 'presse@pr4you.de']))->toBe($existing->id); }); test('a company on the other portal becomes both and the release lands on the requested portal', function () { /** @var TestCase $this */ $user = assignmentApiUser(Portal::Presseecho); $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); $category = Category::factory()->create(['portal' => Portal::Both]); Queue::fake(); $id = postApiCompany(); expect($id)->toBe($existing->id) ->and($existing->fresh()->portal)->toBe(Portal::Both); $this->postJson('/service/pressreleases', [ 'press_release' => [ 'company_id' => $id, 'category_id' => $category->legacy_id ?? $category->id, 'language' => 'de', 'title' => 'Zuordnungs-Test', 'text' => 'Inhalt der Test-Pressemitteilung mit genug Text.', ], ])->assertCreated(); // `both` ist kein Ziel-Portal für eine Meldung — ohne Parameter und // Domain-Kontext entscheidet das Portal des API-Nutzers. $release = PressRelease::withoutGlobalScopes()->where('title', 'Zuordnungs-Test')->firstOrFail(); expect($release->portal)->toBe(Portal::Presseecho) ->and($release->company_id)->toBe($existing->id) ->and($release->user_id)->toBe($user->id); }); test('a slug taken on the other portal prevents widening and falls back to a new company', function () { /** @var TestCase $this */ assignmentApiUser(Portal::Presseecho); $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24', 'slug' => 'muster-gmbh']); Company::factory()->create(['email' => 'anders@fremd.de', 'name' => 'Muster GmbH', 'portal' => 'presseecho', 'slug' => 'muster-gmbh']); $id = postApiCompany(); expect($id)->not->toBe($existing->id) ->and($existing->fresh()->portal)->toBe(Portal::Businessportal24); }); test('a merged-away company is not matched, its survivor is', function () { /** @var TestCase $this */ assignmentApiUser(Portal::Presseecho); $survivor = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'both']); $gone = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'presseecho']); $gone->delete(); expect(postApiCompany())->toBe($survivor->id); }); test('without an email a company is created as before', function () { /** @var TestCase $this */ assignmentApiUser(); $existing = Company::factory()->create(['email' => null, 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); expect(postApiCompany(['email' => null]))->not->toBe($existing->id); }); /* * R-01 (Umsetzungsaudit 29.09.2026): Firmenname und Kontaktadresse stehen * öffentlich im Newsroom. Ohne Freischaltung darf ein fremdes Token darüber * keine Mitgliedschaft (Schreibrecht auf alle Firmen-PMs) erlangen. */ test('a foreign token with matching email and name gets its own company and no membership', function () { /** @var TestCase $this */ $user = assignmentApiUser(Portal::Presseecho, allowlisted: false); $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); $id = postApiCompany(); expect($id)->not->toBe($existing->id) ->and($existing->users()->whereKey($user->id)->exists())->toBeFalse() ->and($existing->fresh()->portal)->toBe(Portal::Businessportal24) ->and(Company::withoutGlobalScopes()->find($id)->owner_user_id)->toBe($user->id) ->and(ActivityEvent::query()->where('subject_id', $existing->id)->where('description', 'like', 'API-Firma%')->exists())->toBeFalse(); }); test('a user who already belongs to the company is reused without allowlist and keeps his role', function () { /** @var TestCase $this */ $user = assignmentApiUser(allowlisted: false); $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); $existing->users()->attach($user->id, ['role' => 'responsible', 'granted_via' => 'manual']); expect(postApiCompany())->toBe($existing->id) ->and($existing->users()->whereKey($user->id)->first()->pivot->role)->toBe('responsible') ->and($existing->users()->whereKey($user->id)->first()->pivot->granted_via)->toBe('manual'); }); test('the owner of the company is reused without allowlist and gets no extra pivot', function () { /** @var TestCase $this */ $user = assignmentApiUser(allowlisted: false); $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24', 'owner_user_id' => $user->id]); expect(postApiCompany())->toBe($existing->id) ->and($existing->users()->whereKey($user->id)->exists())->toBeFalse(); }); test('the login sync never revokes an api membership', function () { /** @var TestCase $this */ $user = assignmentApiUser(); $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); postApiCompany(); app(ContactAccessService::class)->syncMembershipsForEmail($user->fresh()); expect($existing->users()->whereKey($user->id)->wherePivot('granted_via', 'api')->exists())->toBeTrue(); }); test('the review command lists api assignments and removes foreign ones only with --remove', function () { /** @var TestCase $this */ $allowed = assignmentApiUser(); $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); postApiCompany(); // Altfall vor dem Fix: ein fremdes Konto wurde per API-Zuordnung Mitglied. $stranger = User::factory()->create(); $existing->users()->attach($stranger->id, ['role' => 'member', 'granted_via' => 'manual']); ActivityEvent::query()->create([ 'type' => 'correction', 'occurred_at' => now(), 'user_id' => $stranger->id, 'actor_label' => 'api', 'subject_type' => $existing->getMorphClass(), 'subject_id' => $existing->id, 'description' => 'API-Firma „Muster GmbH“ dem bestehenden Newsroom #'.$existing->id.' „Muster GmbH“ zugeordnet (info@muster.de)', 'meta' => ['api_newsroom_assignment' => true], ]); // Mitglied schon vor der Zuordnung: bleibt unangetastet. $veteran = User::factory()->create(); $existing->users()->attach($veteran->id, ['role' => 'member', 'granted_via' => 'manual', 'created_at' => now()->subMonth(), 'updated_at' => now()->subMonth()]); ActivityEvent::query()->create([ 'type' => 'correction', 'occurred_at' => now(), 'user_id' => $veteran->id, 'actor_label' => 'api', 'subject_type' => $existing->getMorphClass(), 'subject_id' => $existing->id, 'description' => 'API-Firma „Muster GmbH“ dem bestehenden Newsroom #'.$existing->id.' „Muster GmbH“ zugeordnet (info@muster.de)', 'meta' => ['api_newsroom_assignment' => true], ]); $this->artisan('company-memberships:api-assignments') ->expectsOutputToContain('2 über die API-Zuordnung entstandene Mitgliedschaft(en), davon 1 ohne Freischaltung') ->assertSuccessful(); expect($existing->users()->whereKey($stranger->id)->exists())->toBeTrue(); $this->artisan('company-memberships:api-assignments', ['--remove' => true, '--force' => true])->assertSuccessful(); expect($existing->users()->whereKey($stranger->id)->exists())->toBeFalse() ->and($existing->users()->whereKey($allowed->id)->exists())->toBeTrue() ->and($existing->users()->whereKey($veteran->id)->exists())->toBeTrue(); });