admin: Reports ▸ Wachstum mit den Abbruchbedingungen, Umami nur lesend angebunden

Roadmap 6.1/6.16: Block 1 bis 3 mit Ist, zeitanteiligem Soll, Schwelle und
Status, Trichter seit 17.09., Transparenzseiten aus Umami. Neue Befehle
growth:report und umami:report. Zugang über UMAMI_API_* (View-only-Benutzer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-13 14:55:29 +00:00
parent e8cf684f02
commit b4870f98bb
12 changed files with 1098 additions and 0 deletions

View file

@ -0,0 +1,225 @@
<?php
use App\Enums\ConversionEventType;
use App\Enums\ConversionSource;
use App\Models\Company;
use App\Models\ConversionEvent;
use App\Models\FeedFetch;
use App\Models\User;
use App\Services\Analytics\UmamiClient;
use App\Services\Analytics\UmamiUnavailable;
use App\Services\Growth\GrowthReport;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
/**
* Admin ▸ Reports ▸ Wachstum (Roadmap 6.1, 6.16): Abbruchbedingungen der
* Blöcke 1 bis 3 aus eigenen Ereignisdaten und Umami.
*/
beforeEach(function (): void {
Http::preventStrayRequests();
Cache::flush();
config()->set('services.umami.api_url', null);
});
function connectUmami(): void
{
config()->set('services.umami.api_url', 'https://umami.test');
config()->set('services.umami.api_username', 'reports');
config()->set('services.umami.api_password', 'geheim');
}
function growthEvent(ConversionEventType $type, ConversionSource $source, ?Company $company, string $at = '2026-10-01'): void
{
ConversionEvent::query()->create(['type' => $type, 'source' => $source, 'company_id' => $company?->id])
->forceFill(['created_at' => Carbon::parse($at)])->save();
}
function growthCriterion(array $report, string $key): array
{
foreach ($report['blocks'] as $block) {
foreach ($block['criteria'] as $criterion) {
if ($criterion['key'] === $key) {
return $criterion;
}
}
}
throw new RuntimeException('Bedingung fehlt: '.$key);
}
// ----------------------------------------------------------- Umami-Client
test('the umami client logs in once, keeps the token and sends the range in milliseconds', function () {
/** @var TestCase $this */
connectUmami();
Http::fake([
'umami.test/api/auth/login' => Http::response(['token' => 'tok-1']),
'umami.test/api/websites/*/stats*' => Http::response(['pageviews' => 12, 'visitors' => 5, 'visits' => 7, 'bounces' => 2, 'totaltime' => 90]),
]);
$client = app(UmamiClient::class);
$from = Carbon::parse('2026-09-17 00:00:00', 'UTC');
$to = Carbon::parse('2026-10-13 00:00:00', 'UTC');
$client->stats('site-1', $from, $to, ['path' => 'c.preise']);
$stats = $client->stats('site-1', $from, $to);
expect($stats['pageviews'])->toBe(12);
Http::assertSentCount(3);
Http::assertSent(fn (Request $request): bool => str_contains($request->url(), '/api/websites/site-1/stats')
&& (string) ($request->data()['startAt'] ?? '') === (string) $from->getTimestampMs()
&& ($request->data()['path'] ?? null) === 'c.preise'
&& $request->hasHeader('Authorization', 'Bearer tok-1'));
});
test('an expired token is renewed once', function () {
/** @var TestCase $this */
connectUmami();
Http::fake([
'umami.test/api/auth/login' => Http::sequence()->push(['token' => 'alt'])->push(['token' => 'neu']),
'umami.test/api/websites/*' => Http::sequence()->push([], 401)->push([['x' => 'google.com', 'y' => 4]]),
]);
$rows = app(UmamiClient::class)->metrics('site-1', 'referrer', now()->subDay(), now());
expect($rows)->toBe([['x' => 'google.com', 'y' => 4]]);
});
test('a rejected login is reported as unavailable, never as an empty result', function () {
/** @var TestCase $this */
connectUmami();
Http::fake(['umami.test/api/auth/login' => Http::response(['error' => 'incorrect-username-password'], 401)]);
app(UmamiClient::class)->stats('site-1', now()->subDay(), now());
})->throws(UmamiUnavailable::class, 'Umami-Anmeldung abgelehnt (401).');
// -------------------------------------------------------------- Bericht
test('block one counts companies from mail and claim, not organic ones and not before the start', function () {
/** @var TestCase $this */
[$a, $b, $c] = Company::factory()->count(3)->create();
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $a);
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $a, '2026-10-02');
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::ProfileClaim, $b);
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::Organic, $c);
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $c, '2026-09-01');
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, null);
growthEvent(ConversionEventType::FirstBooking, ConversionSource::MailPublish, $a);
growthEvent(ConversionEventType::FirstBooking, ConversionSource::Organic, $b);
growthEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailReject, $c);
$report = app(GrowthReport::class)->build(now: Carbon::parse('2026-10-13'));
$claims = growthCriterion($report, 'claims');
expect($claims['actual'])->toBe(2)
->and($claims['note'])->toContain('1 Einlösung ohne Firmenbezug')
->and(growthCriterion($report, 'bookings')['actual'])->toBe(1)
->and(growthCriterion($report, 'reject_clicks')['actual'])->toBe(1);
});
test('cumulative conditions compare against the share of time that has passed', function () {
/** @var TestCase $this */
$companies = Company::factory()->count(16)->create();
foreach ($companies as $company) {
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $company);
}
// Halbzeit (17.12.): Soll 15 von 30.
$mid = app(GrowthReport::class)->build(now: Carbon::parse('2026-12-17'));
expect(growthCriterion($mid, 'claims')['status'])->toBe('on_track')
->and(growthCriterion($mid, 'bookings')['status'])->toBe('behind');
// Nach dem Ende ist „noch nicht“ ein „verfehlt“.
$after = app(GrowthReport::class)->build(now: Carbon::parse('2027-03-20'));
expect(growthCriterion($after, 'claims')['status'])->toBe('missed');
});
test('block three sums the subscribers reader services report', function () {
/** @var TestCase $this */
FeedFetch::factory()->reader('feedly', 12)->create();
FeedFetch::factory()->category('it')->reader('inoreader', 4)->create();
FeedFetch::factory()->reader(null)->create();
expect(growthCriterion(app(GrowthReport::class)->build(), 'feed_subscribers')['actual'])->toBe(16);
});
test('without umami the search condition says why instead of showing zero', function () {
/** @var TestCase $this */
$report = app(GrowthReport::class)->build();
$search = growthCriterion($report, 'search_views');
expect($search['actual'])->toBeNull()
->and($search['status'])->toBe('unknown')
->and($report['umami']['error'])->toContain('nicht verbunden');
});
test('block two counts only visits from search engines', function () {
/** @var TestCase $this */
connectUmami();
config()->set('growth.block2.brands', ['businessportal24']);
config()->set('growth.block2.pages', ['was-kostet-eine-pressemitteilung']);
Http::fake([
'umami.test/api/auth/login' => Http::response(['token' => 'tok']),
'umami.test/api/websites/*/stats*' => Http::response(['pageviews' => 9]),
'umami.test/api/websites/*/metrics*' => Http::response([
['x' => 'google.com', 'y' => 3],
['x' => 'duckduckgo.com', 'y' => 1],
['x' => 'pressekonto.com', 'y' => 4],
]),
]);
$report = app(GrowthReport::class)->build();
expect(growthCriterion($report, 'search_views')['actual'])->toBe(4)
->and($report['pages'][0])->toMatchArray(['views' => 9, 'search_views' => 4]);
Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'metrics') && ($request->data()['path'] ?? null) === 'c.was-kostet-eine-pressemitteilung');
});
// --------------------------------------------------- Befehle und Seite
test('the growth command prints every block', function () {
/** @var TestCase $this */
$this->artisan('growth:report')
->expectsOutputToContain('Block 1 — Konversion aus dem Bestand')
->expectsOutputToContain('Block 2 — Organische Sichtbarkeit')
->expectsOutputToContain('Block 3 — Reichweite')
->assertSuccessful();
});
test('the umami command refuses to run without access data', function () {
/** @var TestCase $this */
$this->artisan('umami:report')
->expectsOutputToContain('Umami-API nicht konfiguriert')
->assertFailed();
});
test('admins see the growth page', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin)
->get(route('admin.reports.growth'))
->assertSuccessful()
->assertSeeText('Wachstum')
->assertSeeText('Firmen mit eingelöstem Verwaltungslink')
->assertSeeText('Gemeldete Feed-Abonnenten (30 Tage)')
->assertSeeText('Umami-API nicht verbunden');
});
test('customers do not see the growth page', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$this->actingAs($customer)->get(route('admin.reports.growth'))->assertForbidden();
});