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