customer: Sichtbarkeit belegen - Aufrufe im Verlauf, Newsroom-Uebersicht (Roadmap 1c)
- VisibilityStats: Firmen-Kennzahlen ueber alle Meldungen, Aufrufe je Meldung (30 Tage), lueckenlose Tagesreihe (Tage vor der Zaehlung sind null, nicht 0), Sitemap-seit und Google-Suchlink (Indexierung Variante B). - Firmenseite: alle Meldungen der Firma statt nur eigene (Beansprucher sahen eine leere Liste), Aufrufe-Spalten, echtes Statistik-Panel. - Meldungsseite: Panel Sichtbarkeit mit SVG-Tagesbalken 30/90 Tage. - Claim aus der Veroeffentlichungsmail landet auf der Meldung (payload press_release_id), Mail-Hinweis erweitert, ein Element (E4). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a00f9dc79a
commit
3ef1b84600
14 changed files with 676 additions and 59 deletions
158
tests/Feature/VisibilityStatsTest.php
Normal file
158
tests/Feature/VisibilityStatsTest.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseEventType;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseEvent;
|
||||
use App\Models\User;
|
||||
use App\Services\PressRelease\VisibilityStats;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Sichtbarkeit belegen (Roadmap 1c): Aufrufe je Firma und je Meldung als
|
||||
* Rohzahlen (E2), sichtbar für jeden, der die Firma verwalten darf — auch
|
||||
* für Meldungen, die ein Partner eingereicht hat.
|
||||
*/
|
||||
function claimant(): array
|
||||
{
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$company = Company::factory()->businessportal24()->create(['name' => 'Muster GmbH']);
|
||||
$customer->companies()->attach($company->id, ['role' => 'member']);
|
||||
test()->actingAs($customer);
|
||||
|
||||
return [$customer, $company];
|
||||
}
|
||||
|
||||
function viewsOn(PressRelease $release, int $count, int $daysAgo = 0): void
|
||||
{
|
||||
// created_at ist nicht fillable — wie im Produktivpfad setzt es das
|
||||
// Model; für die Zeitreihe wird es hier nachträglich verschoben.
|
||||
foreach (range(1, $count) as $i) {
|
||||
$event = PressReleaseEvent::query()->create(['press_release_id' => $release->id, 'type' => PressReleaseEventType::View, 'channel' => null]);
|
||||
$event->forceFill(['created_at' => now()->subDays($daysAgo)->setTime(10, $i % 60)])->saveQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
test('the company summary counts every release of the company, whoever submitted it', function () {
|
||||
/** @var TestCase $this */
|
||||
[, $company] = claimant();
|
||||
$partner = User::factory()->create();
|
||||
|
||||
$old = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'user_id' => $partner->id, 'hits' => 120, 'published_at' => now()->subMonths(2)]);
|
||||
$new = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'user_id' => $partner->id, 'hits' => 30, 'published_at' => now()->subDays(3)]);
|
||||
PressRelease::factory()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'user_id' => $partner->id, 'status' => 'draft', 'hits' => 0]);
|
||||
|
||||
viewsOn($old, 5, daysAgo: 40);
|
||||
viewsOn($old, 2, daysAgo: 2);
|
||||
viewsOn($new, 7, daysAgo: 1);
|
||||
|
||||
$summary = app(VisibilityStats::class)->companySummary($company, 30);
|
||||
|
||||
expect($summary['releases'])->toBe(3)
|
||||
->and($summary['published'])->toBe(2)
|
||||
->and($summary['views_total'])->toBe(150)
|
||||
->and($summary['views_recent'])->toBe(9)
|
||||
->and($summary['last_published_at']->isSameDay($new->published_at))->toBeTrue();
|
||||
|
||||
expect(app(VisibilityStats::class)->recentViewsFor([$old->id, $new->id], 30))->toBe([$old->id => 2, $new->id => 7]);
|
||||
});
|
||||
|
||||
test('the company page lists partner-submitted releases with their views', function () {
|
||||
/** @var TestCase $this */
|
||||
// Vor 1c.3 filterte die Seite nach user_id — ein Beansprucher sah
|
||||
// „Keine Pressemitteilungen", obwohl connektar 68 eingereicht hatte.
|
||||
[, $company] = claimant();
|
||||
$partner = User::factory()->create();
|
||||
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create([
|
||||
'company_id' => $company->id, 'user_id' => $partner->id, 'title' => 'Partnermeldung mit Reichweite', 'hits' => 4321, 'published_at' => now()->subDays(2),
|
||||
]);
|
||||
viewsOn($release, 3, daysAgo: 1);
|
||||
|
||||
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
|
||||
->assertSeeText('Partnermeldung mit Reichweite')
|
||||
->assertSeeText('eingereicht über Partner')
|
||||
->assertSeeText('4.321')
|
||||
->assertSeeText('Aufrufe 30 Tage')
|
||||
->assertDontSeeText('Keine Pressemitteilungen für diese Firma');
|
||||
});
|
||||
|
||||
test('the daily series is gapless, marks days before measurement as unmeasured and respects retention', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('press_releases.events.recorded_since', now()->subDays(10)->toDateString());
|
||||
[, $company] = claimant();
|
||||
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'published_at' => now()->subDays(20)]);
|
||||
viewsOn($release, 4, daysAgo: 3);
|
||||
viewsOn($release, 1, daysAgo: 0);
|
||||
|
||||
$series = app(VisibilityStats::class)->dailyViews($release, 14);
|
||||
|
||||
expect($series)->toHaveCount(14)
|
||||
->and($series[13]['views'])->toBe(1)
|
||||
->and($series[10]['views'])->toBe(4)
|
||||
->and($series[12]['views'])->toBe(0)
|
||||
// Tage vor Beginn der Aufzeichnung: keine Messung, nicht 0.
|
||||
->and($series[0]['views'])->toBeNull()
|
||||
->and($series[2]['views'])->toBeNull()
|
||||
// Der Starttag der Aufzeichnung selbst zählt schon.
|
||||
->and($series[3]['views'])->toBe(0);
|
||||
|
||||
config()->set('press_releases.events.retention_days', 5);
|
||||
expect(app(VisibilityStats::class)->dailyViews($release, 400))->toHaveCount(5);
|
||||
});
|
||||
|
||||
test('the release summary reports sitemap presence and a search link only for public releases', function () {
|
||||
/** @var TestCase $this */
|
||||
[, $company] = claimant();
|
||||
$public = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'published_at' => now()->subDay(), 'hits' => 9]);
|
||||
$draft = PressRelease::factory()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'status' => 'draft']);
|
||||
|
||||
PressReleaseEvent::query()->create(['press_release_id' => $public->id, 'type' => PressReleaseEventType::Share, 'channel' => 'linkedin']);
|
||||
viewsOn($public, 2, daysAgo: 1);
|
||||
|
||||
$stats = app(VisibilityStats::class);
|
||||
$summary = $stats->releaseSummary($public);
|
||||
|
||||
expect($summary['hits'])->toBe(9)
|
||||
->and($summary['shares_total'])->toBe(1)
|
||||
->and($summary['in_sitemap_since']->isSameDay($public->published_at))->toBeTrue()
|
||||
->and($summary['search_url'])->toContain('site%3A')
|
||||
->and($summary['search_url'])->toContain($public->slug)
|
||||
->and($summary['first_view_at'])->not->toBeNull();
|
||||
|
||||
$draftSummary = $stats->releaseSummary($draft);
|
||||
|
||||
expect($draftSummary['in_sitemap_since'])->toBeNull()
|
||||
->and($draftSummary['search_url'])->toBeNull();
|
||||
});
|
||||
|
||||
test('the release page shows the visibility panel with daily bars and the search link', function () {
|
||||
/** @var TestCase $this */
|
||||
[$customer, $company] = claimant();
|
||||
$partner = User::factory()->create();
|
||||
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create([
|
||||
'company_id' => $company->id, 'user_id' => $partner->id, 'hits' => 777, 'published_at' => now()->subDays(5),
|
||||
]);
|
||||
viewsOn($release, 6, daysAgo: 2);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])
|
||||
->assertSeeText('Sichtbarkeit')
|
||||
->assertSeeText('777')
|
||||
->assertSeeText('Aufrufe 30 Tage')
|
||||
->assertSeeText('Bei Google prüfen')
|
||||
->assertSeeText('In der Sitemap für Suchmaschinen seit '.now()->subDays(5)->format('d.m.Y'))
|
||||
->assertSee('<svg', false)
|
||||
->set('viewRange', 90)
|
||||
->assertSeeText('Aufrufe 90 Tage');
|
||||
});
|
||||
|
||||
test('a draft shows no visibility panel', function () {
|
||||
/** @var TestCase $this */
|
||||
[$customer, $company] = claimant();
|
||||
$draft = PressRelease::factory()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'user_id' => $customer->id, 'status' => 'draft', 'hits' => 0, 'published_at' => null]);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $draft->id])
|
||||
->assertDontSeeText('Bei Google prüfen')
|
||||
->assertSeeText('noch nicht veröffentlicht');
|
||||
});
|
||||
|
|
@ -182,7 +182,8 @@ test('the publish mail carries the manage hint at the bottom, tagged with its or
|
|||
// E4 (15.09.2026): Hinweis in der Mail, unten, kein Angebot.
|
||||
expect($mail->manageNewsroomUrl)->toStartWith('https://businessportal24.test/de/newsroom/muster-gmbh/verwalten?')
|
||||
->and($mail->manageNewsroomUrl)->toContain('src='.ConversionSource::MailPublish->value)
|
||||
->and($mail->render())->toContain('Diesen Newsroom verwalten');
|
||||
->and($mail->manageNewsroomUrl)->toContain('pr='.$release->id)
|
||||
->and($mail->render())->toContain('Diesen Newsroom verwalten und Aufrufe einsehen');
|
||||
|
||||
// Ohne Firma kein Hinweis — und nichts bricht.
|
||||
$orphan = PressRelease::factory()->published()->create(['company_id' => null]);
|
||||
|
|
@ -201,3 +202,31 @@ test('a claim coming from the publish mail keeps the mail origin on the token',
|
|||
expect($link->source)->toBe(ConversionSource::MailPublish->value)
|
||||
->and($link->payload['company_id'])->toBe($company->id);
|
||||
});
|
||||
|
||||
test('a claim from the publish mail lands on that release with its views, a foreign release id is ignored', function () {
|
||||
/** @var TestCase $this */
|
||||
// Roadmap 1c.2: der Link in der Veröffentlichungsmail trägt `pr`, die
|
||||
// Claim-Seite reicht es durch, der Login landet auf der Meldung.
|
||||
$company = claimCompany();
|
||||
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'language' => 'de']);
|
||||
$foreign = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create();
|
||||
|
||||
$this->get(claimUrl().'?src=mail_publish&pr='.$release->id)
|
||||
->assertOk()
|
||||
->assertSee('name="pr" value="'.$release->id.'"', false);
|
||||
|
||||
$this->post(claimUrl(), ['src' => ConversionSource::MailPublish->value, 'pr' => $release->id])->assertSessionHas('newsroom-claim-status');
|
||||
|
||||
$user = User::query()->where('email', 'info@muster.de')->firstOrFail();
|
||||
$link = MagicLink::query()->where('user_id', $user->id)->latest('id')->firstOrFail();
|
||||
expect($link->payload)->toBe(['company_id' => $company->id, 'press_release_id' => $release->id]);
|
||||
|
||||
$plain = str_replace('/magic-login/', '', parse_url(Mail::queued(MagicLoginLink::class)->first()->loginUrl, PHP_URL_PATH));
|
||||
$this->get('https://pressekonto.test/magic-login/'.ltrim($plain, '/'))
|
||||
->assertRedirect(route('me.press-releases.show', ['id' => $release->id], absolute: false));
|
||||
|
||||
// Eine Meldung einer anderen Firma bleibt draußen — Landung auf der Firma.
|
||||
RateLimiter::clear('newsroom-claim|ip|127.0.0.1');
|
||||
$this->post(claimUrl(), ['pr' => $foreign->id])->assertSessionHas('newsroom-claim-status');
|
||||
expect(MagicLink::query()->where('user_id', $user->id)->latest('id')->firstOrFail()->payload)->toBe(['company_id' => $company->id]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue