seed(RolesAndPermissionsSeeder::class); Mail::fake(); RateLimiter::clear('newsroom-claim|ip|127.0.0.1'); }); function claimCompany(array $attributes = []): Company { return Company::factory()->create(array_merge([ 'portal' => Portal::Businessportal24->value, 'name' => 'Muster GmbH', 'slug' => 'muster-gmbh', 'email' => 'info@muster.de', 'is_active' => true, ], $attributes)); } function claimUrl(string $slug = 'muster-gmbh'): string { return 'https://businessportal24.test/de/newsroom/'.$slug.'/verwalten'; } test('the claim page is reachable without a single published release and shows only a masked address', function () { /** @var TestCase $this */ claimCompany(); // Die öffentliche Newsroom-Seite gibt es noch nicht — den Zugang schon. $this->get('https://businessportal24.test/de/newsroom/muster-gmbh')->assertNotFound(); $this->get(claimUrl()) ->assertOk() ->assertSeeText('Muster GmbH') ->assertSeeText('i***@muster.de') ->assertDontSeeText('info@muster.de'); }); test('requesting the link creates the account, sends the login link with profile_claim on the token and lands on the company page', function () { /** @var TestCase $this */ $company = claimCompany(); $this->post(claimUrl())->assertRedirect()->assertSessionHas('newsroom-claim-status'); $user = User::query()->where('email', 'info@muster.de')->firstOrFail(); expect($user->accessibleCompanyIds())->toContain($company->id); Mail::assertQueued(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user)); $link = MagicLink::query()->where('user_id', $user->id)->where('purpose', 'login')->latest('id')->firstOrFail(); expect($link->source)->toBe(ConversionSource::ProfileClaim->value) ->and($link->payload['company_id'])->toBe($company->id); // Token aus der Mail nachstellen: Der Login landet auf der Firma, nicht // auf dem Dashboard — und Stufe 2 der Leiter trägt die Herkunft. $plain = str_replace('/magic-login/', '', parse_url(Mail::queued(MagicLoginLink::class)->first()->loginUrl, PHP_URL_PATH)); $this->get('https://pressekonto.test/magic-login/'.ltrim($plain, '/')) ->assertRedirect(route('me.press-kits.show', ['id' => $company->id], absolute: false)); expect(ConversionEvent::query()->where('type', ConversionEventType::MagicLinkConsumed->value)->first()->source) ->toBe(ConversionSource::ProfileClaim); }); test('without a company email the link goes to the press contacts', function () { /** @var TestCase $this */ $company = claimCompany(['email' => null]); Contact::factory()->for($company)->create(['email' => 'Paula@muster.de', 'portal' => 'businessportal24']); Contact::factory()->for($company)->create(['email' => 'peter@muster.de', 'portal' => 'businessportal24']); $this->get(claimUrl())->assertSeeText('p***@muster.de'); $this->post(claimUrl())->assertSessionHas('newsroom-claim-status'); Mail::assertQueuedCount(2); }); test('without any address the page explains it and sends nothing', function () { /** @var TestCase $this */ claimCompany(['email' => null]); $this->get(claimUrl())->assertOk()->assertSeeText('keine E-Mail-Adresse hinterlegt'); $this->post(claimUrl())->assertSessionHas('newsroom-claim-none'); Mail::assertNothingQueued(); }); test('links per newsroom are limited per day', function () { /** @var TestCase $this */ $company = claimCompany(); RateLimiter::clear('newsroom-claim|company|'.$company->id); config()->set('newsroom.claim.links_per_day', 2); $this->post(claimUrl())->assertSessionHas('newsroom-claim-status'); $this->post(claimUrl())->assertSessionHas('newsroom-claim-status'); $this->post(claimUrl())->assertSessionHasErrors('claim'); Mail::assertQueuedCount(2); }); test('a removed newsroom answers 410 and a merged-away slug redirects to the survivor', function () { /** @var TestCase $this */ $gone = claimCompany(['slug' => 'weg-gmbh', 'email' => 'weg@muster.de']); app(CompanyRemovalService::class)->markGone($gone); $this->get(claimUrl('weg-gmbh'))->assertStatus(410); $this->post(claimUrl('weg-gmbh'))->assertStatus(410); // Zusammenführung: alte Hälfte auf PE mit anderem Slug geht im // B24-Überlebenden auf → der Zugang folgt dem 301 der Newsroom-Seite. claimCompany(); claimCompany(['portal' => Portal::Presseecho->value, 'slug' => 'muster']); CompanyEmailGroup::query()->create([ 'email' => 'info@muster.de', 'classification' => 'dublette', 'companies_count' => 2, 'distinct_names' => 1, 'published_companies_count' => 0, 'threshold' => 3, 'classified_at' => now(), ]); $service = app(CompanyMergeService::class); $service->merge($service->plan(CompanyEmailGroup::forEmail('info@muster.de')), 'b'); $this->get('https://presseecho.test/de/newsroom/muster/verwalten') ->assertRedirect('https://presseecho.test/de/newsroom/muster-gmbh/verwalten') ->assertStatus(301); $this->get('https://businessportal24.test/de/newsroom/unbekannt/verwalten')->assertNotFound(); }); test('the honeypot answers neutrally without sending', function () { /** @var TestCase $this */ claimCompany(); $this->post(claimUrl(), ['website' => 'http://spam'])->assertSessionHas('newsroom-claim-status'); Mail::assertNothingQueued(); }); // ------------------------------------------------- 1b.4 Hinweise test('the newsroom page links to the claim page as a hint', function () { /** @var TestCase $this */ $company = claimCompany(); PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create([ 'company_id' => $company->id, 'published_at' => now()->subDay(), 'language' => 'de', ]); $this->get('https://businessportal24.test/de/newsroom/muster-gmbh') ->assertOk() ->assertSeeText('Diesen Newsroom verwalten') ->assertSee('/de/newsroom/muster-gmbh/verwalten', false); }); test('the publish mail carries the manage hint at the bottom, tagged with its origin', function () { /** @var TestCase $this */ $company = claimCompany(); $release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create([ 'company_id' => $company->id, 'language' => 'de', ]); $mail = new PressReleasePublished($release->user, $release); // E4 (15.09.2026): Hinweis in der Mail, unten, kein Angebot. expect($mail->manageNewsroomUrl)->toStartWith('https://businessportal24.test/de/newsroom/muster-gmbh/verwalten?') ->and($mail->manageNewsroomUrl)->toContain('src='.ConversionSource::MailPublish->value) ->and($mail->manageNewsroomUrl)->toContain('pr='.$release->id) ->and($mail->render())->toContain('Diesen Newsroom verwalten und Aufrufe einsehen'); // Ohne Firma kein Hinweis — und nichts bricht. $orphan = PressRelease::factory()->published()->create(['company_id' => null]); expect((new PressReleasePublished($orphan->user, $orphan))->manageNewsroomUrl)->toBeNull(); }); test('a claim coming from the publish mail keeps the mail origin on the token', function () { /** @var TestCase $this */ $company = claimCompany(); $this->post(claimUrl(), ['src' => ConversionSource::MailPublish->value])->assertSessionHas('newsroom-claim-status'); $user = User::query()->where('email', 'info@muster.de')->firstOrFail(); $link = MagicLink::query()->where('user_id', $user->id)->latest('id')->firstOrFail(); expect($link->source)->toBe(ConversionSource::MailPublish->value) ->and($link->payload['company_id'])->toBe($company->id); }); test('a claim from the publish mail lands on that release with its views, a foreign release id is ignored', function () { /** @var TestCase $this */ // Roadmap 1c.2: der Link in der Veröffentlichungsmail trägt `pr`, die // Claim-Seite reicht es durch, der Login landet auf der Meldung. $company = claimCompany(); $release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'language' => 'de']); $foreign = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(); $this->get(claimUrl().'?src=mail_publish&pr='.$release->id) ->assertOk() ->assertSee('name="pr" value="'.$release->id.'"', false); $this->post(claimUrl(), ['src' => ConversionSource::MailPublish->value, 'pr' => $release->id])->assertSessionHas('newsroom-claim-status'); $user = User::query()->where('email', 'info@muster.de')->firstOrFail(); $link = MagicLink::query()->where('user_id', $user->id)->latest('id')->firstOrFail(); expect($link->payload)->toBe(['company_id' => $company->id, 'press_release_id' => $release->id]); $plain = str_replace('/magic-login/', '', parse_url(Mail::queued(MagicLoginLink::class)->first()->loginUrl, PHP_URL_PATH)); $this->get('https://pressekonto.test/magic-login/'.ltrim($plain, '/')) ->assertRedirect(route('me.press-releases.show', ['id' => $release->id], absolute: false)); // Eine Meldung einer anderen Firma bleibt draußen — Landung auf der Firma. RateLimiter::clear('newsroom-claim|ip|127.0.0.1'); $this->post(claimUrl(), ['pr' => $foreign->id])->assertSessionHas('newsroom-claim-status'); expect(MagicLink::query()->where('user_id', $user->id)->latest('id')->firstOrFail()->payload)->toBe(['company_id' => $company->id]); }); test('attempts without a sent link do not use up the daily links of the newsroom', function () { /** @var TestCase $this */ $company = claimCompany(['email' => null]); RateLimiter::clear('newsroom-claim|company|'.$company->id); config()->set('newsroom.claim.links_per_day', 2); // Umsetzungsaudit 29.09.2026, R-09: Ohne Versand zählt der Firmenzähler // nicht — Dritte können den Inhaber so nicht aussperren. foreach (range(1, 3) as $i) { $this->post(claimUrl())->assertSessionHas('newsroom-claim-none')->assertSessionHasNoErrors(); } $company->update(['email' => 'info@muster.de']); $this->post(claimUrl())->assertSessionHas('newsroom-claim-status')->assertSessionHasNoErrors(); Mail::assertQueuedCount(1); }); test('the default daily link budget per newsroom is moderate', function () { expect((int) config('newsroom.claim.links_per_day'))->toBe(5); });