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:
parent
e8cf684f02
commit
b4870f98bb
12 changed files with 1098 additions and 0 deletions
225
tests/Feature/Admin/GrowthReportTest.php
Normal file
225
tests/Feature/Admin/GrowthReportTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue