- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt() für Sitemap-lastmod, dateModified und article:modified_time - R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen - R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt — hart wie weich — nur ein gültig signierter Rückläufer, unsignierte standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy) - R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und Unmerge, assignAll setzt veraltete Verweise zurück - R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach 395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert, Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst - N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap - OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt committet ist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
259 lines
14 KiB
PHP
259 lines
14 KiB
PHP
<?php
|
|
|
|
use App\Enums\IndexState;
|
|
use App\Enums\Portal;
|
|
use App\Enums\PressReleaseEventType;
|
|
use App\Models\Category;
|
|
use App\Models\Company;
|
|
use App\Models\PressRelease;
|
|
use App\Models\PressReleaseCrawlerHit;
|
|
use App\Models\PressReleaseEvent;
|
|
use App\Models\PressReleaseIndexCheck;
|
|
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 only for public releases and lists crawler fetches', function () {
|
|
/** @var TestCase $this */
|
|
[, $company] = claimant();
|
|
$category = Category::factory()->create(['portal' => Portal::Businessportal24]);
|
|
$category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']);
|
|
$public = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'category_id' => $category->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);
|
|
|
|
PressReleaseCrawlerHit::factory()->service('google', 'Mozilla/5.0 (compatible; Googlebot/2.1)')->count(2)->create(['press_release_id' => $public->id]);
|
|
PressReleaseCrawlerHit::factory()->count(1)->create(['press_release_id' => $public->id]);
|
|
|
|
$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)->not->toHaveKey('search_url')
|
|
->and(collect($summary['crawlers'])->pluck('fetches', 'service')->sortKeys()->all())->toBe(['google' => 2, 'openai' => 1])
|
|
->and(collect($summary['crawlers'])->firstWhere('service', 'google')['label'])->toBe('Google Suche')
|
|
->and($summary['first_view_at'])->not->toBeNull()
|
|
// RSS-Feeds (3.1): Branchen-Feed und Portal-Feed des eigenen Portals.
|
|
->and(collect($summary['feeds'])->pluck('url')->all())->toBe([
|
|
'https://businessportal24.test/de/category/handel/feed',
|
|
'https://businessportal24.test/de/newsroom/'.$company->slug.'/feed',
|
|
'https://businessportal24.test/de/feed',
|
|
]);
|
|
|
|
$draftSummary = $stats->releaseSummary($draft);
|
|
|
|
expect($draftSummary['in_sitemap_since'])->toBeNull()
|
|
->and($draftSummary['crawlers'])->toBe([])
|
|
->and($draftSummary['feeds'])->toBe([]);
|
|
});
|
|
|
|
test('the release page shows the delivery panel with daily bars, sitemap date and crawler fetches, but no 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);
|
|
PressReleaseCrawlerHit::factory()->service('anthropic', 'ClaudeBot/1.0')->create(['press_release_id' => $release->id]);
|
|
|
|
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])
|
|
->assertSeeText('Auslieferung')
|
|
->assertSeeText('777')
|
|
->assertSeeText('Aufrufe 30 Tage')
|
|
->assertDontSeeText('Bei Google prüfen')
|
|
->assertDontSee('google.com/search', false)
|
|
->assertSeeText('Gelistet seit '.now()->subDays(5)->format('d.m.Y'))
|
|
->assertSeeText('Anthropic / Claude')
|
|
->assertSeeText('1 Abruf')
|
|
->assertSeeText('Ein Abruf belegt, dass der Crawler des Dienstes die Seite geholt hat')
|
|
->assertSeeText('RSS-Feeds')
|
|
->assertSee('https://businessportal24.test/de/feed', false)
|
|
// Indexierungsstatus bleibt zu (E8), solange die Kundenansicht nicht freigegeben ist.
|
|
->assertDontSeeText('Google-Index')
|
|
->assertSee('<svg', false)
|
|
->set('viewRange', 90)
|
|
->assertSeeText('Aufrufe 90 Tage');
|
|
});
|
|
|
|
test('the index status appears in the delivery panel only once the customer view is released', function () {
|
|
/** @var TestCase $this */
|
|
[, $company] = claimant();
|
|
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'published_at' => now()->subDays(5)]);
|
|
PressReleaseIndexCheck::factory()->withState(IndexState::CrawledNotIndexed)->create(['press_release_id' => $release->id, 'portal' => Portal::Businessportal24, 'checked_at' => now()->subDay()]);
|
|
PressReleaseIndexCheck::factory()->withState(IndexState::Indexed)->create(['press_release_id' => $release->id, 'portal' => Portal::Businessportal24, 'checked_at' => now()->subDays(9)]);
|
|
|
|
config()->set('search_console.customer_visible', false);
|
|
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])
|
|
->assertDontSeeText('Google-Index');
|
|
|
|
config()->set('search_console.customer_visible', true);
|
|
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])
|
|
->assertSeeText('Google-Index')
|
|
->assertSeeText('Businessportal24: gecrawlt, nicht indexiert')
|
|
->assertDontSeeText('Businessportal24: indexiert');
|
|
});
|
|
|
|
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('Auslieferung')
|
|
->assertSeeText('noch nicht veröffentlicht');
|
|
});
|
|
|
|
test('the release summary reports the sitemap role: the source is listed, a copy is not', function () {
|
|
/** @var TestCase $this */
|
|
[, $company] = claimant();
|
|
config()->set('canonical.enabled', true);
|
|
config()->set('canonical.portal_by_category', ['handel' => 'presseecho']);
|
|
|
|
$category = Category::factory()->create(['portal' => Portal::Both]);
|
|
$category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']);
|
|
|
|
$source = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'category_id' => $category->id, 'published_at' => now()->subDay()]);
|
|
$copy = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->create(['company_id' => $company->id, 'category_id' => $category->id, 'published_at' => now()->subDay(), 'canonical_press_release_id' => $source->id]);
|
|
$both = PressRelease::factory()->published()->forPortal(Portal::Both)->create(['company_id' => $company->id, 'category_id' => $category->id, 'published_at' => now()->subDay()]);
|
|
|
|
$stats = app(VisibilityStats::class);
|
|
$sourceSummary = $stats->releaseSummary($source);
|
|
$copySummary = $stats->releaseSummary($copy);
|
|
$bothSummary = $stats->releaseSummary($both);
|
|
|
|
expect($sourceSummary['sitemap_role'])->toBe('source')
|
|
->and($sourceSummary['sitemap_portals'])->toBe([Portal::Businessportal24->label()])
|
|
->and($sourceSummary['in_sitemap_since'])->not->toBeNull()
|
|
// Die Kopie ist öffentlich, steht aber in keiner Sitemap (SitemapController, 1c.8).
|
|
->and($copySummary['sitemap_role'])->toBe('copy')
|
|
->and($copySummary['sitemap_portals'])->toBe([])
|
|
->and($copySummary['in_sitemap_since'])->toBeNull()
|
|
// portal=both: nur die Sitemap des Kategorie-Portals führt sie.
|
|
->and($bothSummary['sitemap_role'])->toBe('source')
|
|
->and($bothSummary['sitemap_portals'])->toBe([Portal::Presseecho->label()]);
|
|
|
|
// Ohne Zuordnung ist jede Fassung Erstquelle.
|
|
config()->set('canonical.enabled', false);
|
|
expect($stats->releaseSummary($copy)['sitemap_role'])->toBe('source');
|
|
});
|
|
|
|
test('the listing date is not claimed when the release has a counterpart on the other portal', function () {
|
|
/** @var TestCase $this */
|
|
[$customer, $company] = claimant();
|
|
config()->set('canonical.enabled', true);
|
|
|
|
// Fassung auf dem anderen Portal, inzwischen depubliziert: Die Kopie ist
|
|
// erst seitdem Erstquelle — wann genau, ist nicht gespeichert (N17-Rest).
|
|
$former = PressRelease::factory()->forPortal(Portal::Presseecho)->create(['company_id' => $company->id, 'title' => 'Gleich', 'text' => 'Gleicher Text', 'status' => 'archived', 'published_at' => now()->subDays(20)]);
|
|
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'user_id' => $customer->id, 'title' => 'Gleich', 'text' => 'Gleicher Text', 'published_at' => now()->subDays(10)]);
|
|
expect($former->content_hash)->toBe($release->content_hash);
|
|
|
|
$summary = app(VisibilityStats::class)->releaseSummary($release);
|
|
|
|
expect($summary['sitemap_role'])->toBe('source')
|
|
->and($summary['in_sitemap_since'])->toBeNull()
|
|
->and($summary['sitemap_portals'])->toBe([Portal::Businessportal24->label()]);
|
|
|
|
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])
|
|
->assertSeeText('In der Sitemap gelistet (Businessportal24)')
|
|
->assertDontSeeText('Gelistet seit')
|
|
->assertDontSeeText('Derzeit nicht gelistet');
|
|
});
|