Ohne kommerziellen Anteil (E6): Anlass, Aufbau, Sprache, Pruefablauf mit konfigurierbarer Versuchszahl, die sieben Qualitaetsdimensionen offen, Gewichtung nicht (Offenlegungsgrenze), Pressebilder, Partner als BALD. DE und EN, Sitemap, Hilfe-Center. Die Ablehnungsmail verlinkt die Seite auf dem Portal der Meldung mit Herkunft mail_reject. Config: yellow_to_manual_queue Vorgabe false (Entscheidung 16.09.2026), max_automatic_attempts = 3 (E3). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
269 lines
11 KiB
PHP
269 lines
11 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');
|
|
});
|