presseportale/tests/Feature/ConversionFunnelTest.php
Kevin Adametz d6e49c11c3 web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)
- 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>
2026-10-02 13:09:00 +00:00

299 lines
13 KiB
PHP

<?php
use App\Enums\ConversionEventType;
use App\Enums\ConversionSource;
use App\Enums\Portal;
use App\Mail\PressReleasePublished;
use App\Mail\PressReleaseRejected;
use App\Models\Company;
use App\Models\ConversionEvent;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Auth\MagicLinkGenerator;
use App\Services\Conversion\ConversionFunnelReport;
use App\Services\Conversion\ConversionRecorder;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
/**
* Konversionsleiter (Roadmap Block 0). Die Herkunft muss den Domainwechsel
* zwischen Mail, Spoke und Hub überleben — deshalb hängt sie am Token und
* nicht an der Session.
*/
function funnelEvent(ConversionEventType $type, ConversionSource $source, ?Company $company = null): ConversionEvent
{
return ConversionEvent::query()->create([
'type' => $type,
'source' => $source,
'company_id' => $company?->getKey(),
]);
}
// ------------------------------------------------- 0.4 Herkunft am Token
test('a mail link carries its origin and the press release', function () {
/** @var TestCase $this */
$release = PressRelease::factory()->published()->create();
$user = $release->user;
$mail = new PressReleasePublished($user, $release);
expect($mail->showUrl)->toContain('src='.ConversionSource::MailPublish->value)
->and($mail->showUrl)->toContain('pr='.$release->id);
});
test('the rejection mail marks its own origin', function () {
/** @var TestCase $this */
$release = PressRelease::factory()->create();
$mail = new PressReleaseRejected($release->user, $release, 'Grund');
// Ohne eigenen Herkunftswert wäre später nicht unterscheidbar, ob eine
// Wiedereinreichung aus der Ablehnungsmail kam.
expect($mail->editUrl)->toContain('src='.ConversionSource::MailReject->value);
// Roadmap 1d.2: Hilfeseite auf dem Portal der Meldung, gleiche Herkunft,
// im Text der Mail verlinkt — und nichts Kommerzielles daneben (E6).
$html = $mail->render();
expect($mail->helpUrl)->toContain('/gute-pressemitteilung?')
->and($mail->helpUrl)->toContain('src='.ConversionSource::MailReject->value)
->and($mail->helpUrl)->toContain('pr='.$release->id)
->and($html)->toContain('Was eine gute Pressemitteilung ausmacht')
->and($html)->not->toContain('/preise');
});
test('the origin is stored on the magic link token, not in a session', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$link = app(MagicLinkGenerator::class)->createLoginLink($user, null, 15, ConversionSource::MailPublish);
// Genau das ist der Punkt: Die Strecke führt über zwei Domains und einen
// Mailclient. Eine Session reißt dabei, das Token nicht.
expect($link['magic_link']->source)->toBe(ConversionSource::MailPublish->value)
->and(app(ConversionRecorder::class)->sourceOf($link['magic_link']))
->toBe(ConversionSource::MailPublish);
});
test('an unknown origin falls back to organic instead of failing', function () {
/** @var TestCase $this */
// Der Parameter steht in fremder Hand — Mailclient, Weiterleitung, manuell
// getippt. Er darf niemals einen Request scheitern lassen.
expect(ConversionSource::parse('kaputt'))->toBe(ConversionSource::Organic)
->and(ConversionSource::parse(null))->toBe(ConversionSource::Organic);
});
// --------------------------------------------------- 0.5 Schreibstellen
test('a mail link click is recorded with its company', function () {
/** @var TestCase $this */
$company = Company::factory()->create(['portal' => Portal::Businessportal24]);
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)
->create(['company_id' => $company->id, 'published_at' => now()->subDay()]);
$this->get('https://businessportal24.test/de/press-release/'.$release->slug
.'?src='.ConversionSource::MailPublish->value.'&pr='.$release->id)->assertOk();
$event = ConversionEvent::query()->sole();
expect($event->type)->toBe(ConversionEventType::MailLinkClicked)
->and($event->source)->toBe(ConversionSource::MailPublish)
->and($event->company_id)->toBe($company->id)
->and($event->press_release_id)->toBe($release->id);
});
test('a page view without an origin parameter records nothing', function () {
/** @var TestCase $this */
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)
->create(['published_at' => now()->subDay()]);
$this->get('https://businessportal24.test/de/press-release/'.$release->slug)->assertOk();
expect(ConversionEvent::query()->count())->toBe(0);
});
test('an organic origin parameter does not fake a mail click', function () {
/** @var TestCase $this */
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)
->create(['published_at' => now()->subDay()]);
$this->get('https://businessportal24.test/de/press-release/'.$release->slug.'?src=organic')->assertOk();
// Sonst erzeugte jeder getippte Parameter ein Ereignis.
expect(ConversionEvent::query()->count())->toBe(0);
});
test('consuming a magic link records step two with the token origin', function () {
/** @var TestCase $this */
Mail::fake();
$user = User::factory()->create(['is_active' => true]);
$link = app(MagicLinkGenerator::class)->createLoginLink($user, null, 15, ConversionSource::MailReject);
$this->get(route('magic-links.consume', ['token' => $link['plain_token']]));
$event = ConversionEvent::query()
->where('type', ConversionEventType::MagicLinkConsumed->value)
->sole();
expect($event->source)->toBe(ConversionSource::MailReject);
});
test('registering records step four', function () {
/** @var TestCase $this */
event(new Registered(User::factory()->create()));
expect(ConversionEvent::query()
->where('type', ConversionEventType::AccountCreated->value)
->count())->toBe(1);
});
test('the first booking is recorded once per company', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$company = Company::factory()->create(['owner_user_id' => $user->id]);
$recorder = app(ConversionRecorder::class);
$recorder->recordFirstBooking($user);
$recorder->recordFirstBooking($user);
// Eine zweite Buchung ist keine erste. Beide Zahlungspfade dürfen melden,
// ohne die Stufe zu verfälschen.
expect(ConversionEvent::query()
->where('type', ConversionEventType::FirstBooking->value)
->where('company_id', $company->id)
->count())->toBe(1);
});
test('a broken recording never breaks the request it accompanies', function () {
/** @var TestCase $this */
Schema::drop('conversion_events');
// Die Messung begleitet einen Vorgang, sie ist nicht der Vorgang.
app(ConversionRecorder::class)->record(ConversionEventType::AccountCreated, ConversionSource::Organic);
})->throwsNoExceptions();
// ------------------------------------------------------ 0.6 Auswertung
test('the funnel counts distinct companies per step', function () {
/** @var TestCase $this */
$a = Company::factory()->create();
$b = Company::factory()->create();
funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $a);
funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $a);
funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $b);
funnelEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $a);
$data = app(ConversionFunnelReport::class)->build(now()->subMonth());
$steps = collect($data['sources'])->firstWhere('source', ConversionSource::MailPublish->value)['steps'];
// Ein doppelter Klick ist kein doppelter Interessent.
expect($steps[0]['firmen'])->toBe(2)
->and($steps[1]['firmen'])->toBe(1)
->and($steps[1]['uebergang'])->toBe(50.0)
->and($steps[0]['uebergang'])->toBeNull();
});
test('the funnel keeps events without a company out of the quota', function () {
/** @var TestCase $this */
$company = Company::factory()->create();
funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $company);
funnelEvent(ConversionEventType::AccountCreated, ConversionSource::MailPublish, null);
$data = app(ConversionFunnelReport::class)->build(now()->subMonth());
$steps = collect(collect($data['sources'])->firstWhere('source', ConversionSource::MailPublish->value)['steps'])
->keyBy('type');
// „Konto angelegt" hat naturgemäß keine Firma — das gehört ausgewiesen,
// nicht in einen Nenner, dessen Einheit Firmen sind.
expect($steps[ConversionEventType::AccountCreated->value]['firmen'])->toBe(0)
->and($steps[ConversionEventType::AccountCreated->value]['ohne_firma'])->toBe(1);
});
test('the funnel separates the origins', function () {
/** @var TestCase $this */
funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, Company::factory()->create());
funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailReject, Company::factory()->create());
$data = app(ConversionFunnelReport::class)->build(now()->subMonth());
// Ohne diese Trennung wüsste man, dass jemand gebucht hat, aber nicht,
// ob er aus einer Ablehnungsmail kam.
expect(collect($data['sources'])->pluck('source')->all())
->toContain(ConversionSource::MailPublish->value, ConversionSource::MailReject->value);
});
test('the funnel command says plainly when nothing was recorded', function () {
/** @var TestCase $this */
$this->artisan('conversion:funnel')
->expectsOutputToContain('Keine Ereignisse im Zeitraum.')
->assertSuccessful();
});
// ------------------------------------------------------ 0.7 Aufbewahrung
test('the purge drops events past the retention window', function () {
/** @var TestCase $this */
$old = funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish);
$old->forceFill(['created_at' => now()->subDays(400)])->save();
$fresh = funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish);
$this->artisan('conversion:purge-events')->assertSuccessful();
expect(ConversionEvent::query()->find($old->id))->toBeNull()
->and(ConversionEvent::query()->find($fresh->id))->not->toBeNull();
});
test('the purge dry run counts without deleting', function () {
/** @var TestCase $this */
$old = funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish);
$old->forceFill(['created_at' => now()->subDays(400)])->save();
$this->artisan('conversion:purge-events', ['--dry-run' => true])
->expectsOutputToContain('[Dry-Run] 1 Ereignisse')
->assertSuccessful();
expect(ConversionEvent::query()->find($old->id))->not->toBeNull();
});
test('the ladder is complete and ordered', function () {
/** @var TestCase $this */
$steps = array_map(fn (ConversionEventType $t): int => $t->step(), ConversionEventType::ladder());
// Eine Leiter ohne Sprossen sagt nicht, wo es hakt.
expect($steps)->toBe([1, 2, 3, 4, 5])
->and(ConversionEventType::NewsroomEdited->diagnosis())->toBe('Kein Anreiz, das Profil zu pflegen');
});
test('mail link clicks are throttled per address and release and ignore link scanners', function () {
/** @var TestCase $this */
$company = Company::factory()->create();
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)
->create(['company_id' => $company->id, 'published_at' => now()->subDay()]);
$url = 'https://businessportal24.test/de/press-release/'.$release->slug.'?src='.ConversionSource::MailPublish->value.'&pr='.$release->id;
// Umsetzungsaudit 29.09.2026, R-08: Link-Scanner der Mailserver holen
// Links vorab — per HEAD oder mit eigener Kennung.
$this->call('HEAD', $url)->assertOk();
$this->withHeaders(['User-Agent' => 'Mozilla/5.0 (compatible; Barracuda Sentinel)'])->get($url)->assertOk();
$this->withHeaders(['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) BingPreview/1.0b'])->get($url)->assertOk();
expect(ConversionEvent::query()->count())->toBe(0);
// Derselbe Klick mehrfach (Doppelklick, erneutes Öffnen): einmal gezählt.
$browser = ['User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Safari/605.1.15'];
$this->withHeaders($browser)->get($url)->assertOk();
$this->withHeaders($browser)->get($url)->assertOk();
$this->withHeaders($browser)->get($url)->assertOk();
expect(ConversionEvent::query()->where('type', ConversionEventType::MailLinkClicked->value)->count())->toBe(1);
// Eine andere Meldung aus derselben Mail zählt eigenständig.
$other = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)
->create(['company_id' => $company->id, 'published_at' => now()->subDay()]);
$this->withHeaders($browser)->get('https://businessportal24.test/de/press-release/'.$other->slug.'?src='.ConversionSource::MailPublish->value.'&pr='.$other->id)->assertOk();
expect(ConversionEvent::query()->count())->toBe(2);
});