- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt() für Sitemap-lastmod, dateModified und article:modified_time - R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen - R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt — hart wie weich — nur ein gültig signierter Rückläufer, unsignierte standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy) - R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und Unmerge, assignAll setzt veraltete Verweise zurück - R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach 395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert, Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst - N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap - OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt committet ist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
254 lines
11 KiB
PHP
254 lines
11 KiB
PHP
<?php
|
|
|
|
use App\Enums\ConversionEventType;
|
|
use App\Enums\ConversionSource;
|
|
use App\Enums\Portal;
|
|
use App\Mail\MagicLoginLink;
|
|
use App\Mail\PressReleasePublished;
|
|
use App\Models\Company;
|
|
use App\Models\CompanyEmailGroup;
|
|
use App\Models\Contact;
|
|
use App\Models\ConversionEvent;
|
|
use App\Models\MagicLink;
|
|
use App\Models\PressRelease;
|
|
use App\Models\User;
|
|
use App\Services\Company\CompanyMergeService;
|
|
use App\Services\Company\CompanyRemovalService;
|
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* „Diesen Newsroom verwalten" (Roadmap 1b.2): Verwaltungszugang über die
|
|
* hinterlegte Firmen-E-Mail, unabhängig von der öffentlichen Sichtbarkeit.
|
|
*/
|
|
beforeEach(function (): void {
|
|
/** @var TestCase $this */
|
|
$this->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);
|
|
});
|