presseportale/tests/Feature/Web/FeedFetchLogTest.php
Kevin Adametz d6e49c11c3 web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)
- 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>
2026-10-02 13:09:00 +00:00

204 lines
11 KiB
PHP

<?php
use App\Console\Commands\AggregatePressReleaseViews;
use App\Console\Commands\PurgePressReleaseEvents;
use App\Enums\Portal;
use App\Models\AppSetting;
use App\Models\Category;
use App\Models\Company;
use App\Models\FeedFetch;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\PressRelease\FeedFetchReport;
use App\Services\PressRelease\VisibilityStats;
use App\Support\FeedReaderIdentifier;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Abruf-Protokoll der RSS-Feeds (Roadmap 3.1): jeder Feed-Abruf wird mit
* Art, Schlüssel, Reader-Dienst und gemeldeter Abonnentenzahl gespeichert —
* ohne IP. Abrufe zählen wir, Abonnenten meldet der Dienst.
*/
function feedLogCategory(): Category
{
$category = Category::factory()->create(['portal' => Portal::Presseecho, 'is_active' => true]);
$category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']);
return $category;
}
function feedLogRelease(Category $category, Company $company, string $slug): PressRelease
{
return PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)
->create(['slug' => $slug, 'language' => 'de', 'published_at' => now()->subHour()]);
}
test('the identifier maps reader user agents and reads the reported subscriber count', function (?string $userAgent, ?string $reader, ?int $subscribers) {
expect(FeedReaderIdentifier::identify($userAgent))->toBe($reader)
->and(FeedReaderIdentifier::subscribers($userAgent))->toBe($subscribers);
})->with([
['Feedly/1.0 (+http://www.feedly.com/fetcher.html; 12 subscribers; like FeedFetcher-Google)', 'feedly', 12],
['Mozilla/5.0 (compatible; inoreader.com; 5 subscribers; feed-id=123)', 'inoreader', 5],
['NewsBlur Feed Fetcher - 3 subscribers - https://newsblur.com', 'newsblur', 3],
['Feedbin feed-id:99 - 1 subscriber', 'feedbin', 1],
['Mozilla/5.0 (X11; Linux x86_64; rv:115.0) Gecko/20100101 Thunderbird/115.0', 'thunderbird', null],
['NetNewsWire (RSS Reader; https://netnewswire.com/)', 'netnewswire', null],
['Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/537.36 Chrome/128.0 Safari/537.36', 'browser', null],
['curl/8.4.0', null, null],
['', null, null],
[null, null, null],
]);
test('every feed fetch is logged with feed, key, portal, language and reader but without an IP', function () {
/** @var TestCase $this */
$category = feedLogCategory();
$company = Company::factory()->presseecho()->create(['slug' => 'nordwind', 'is_active' => true]);
feedLogRelease($category, $company, 'nordwind-neu');
$feedly = 'Feedly/1.0 (+http://www.feedly.com/fetcher.html; 12 subscribers; like FeedFetcher-Google)';
$this->withHeaders(['User-Agent' => $feedly])->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful();
// Zweiter Abruf kommt aus dem Cache — und wird trotzdem gezählt.
$this->withHeaders(['User-Agent' => $feedly])->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful();
$this->withHeaders(['User-Agent' => 'curl/8.4.0'])->get('https://presseecho.test/de/feed')->assertSuccessful();
$this->withHeaders(['User-Agent' => 'NetNewsWire (RSS Reader; https://netnewswire.com/)'])->get('https://presseecho.test/en/newsroom/nordwind/feed')->assertNotFound();
$this->withHeaders(['User-Agent' => 'NetNewsWire (RSS Reader; https://netnewswire.com/)'])->get('https://presseecho.test/de/newsroom/nordwind/feed')->assertSuccessful();
expect(FeedFetch::query()->count())->toBe(4);
$categoryFetch = FeedFetch::query()->where('feed', FeedFetch::FEED_CATEGORY)->first();
expect($categoryFetch->feed_key)->toBe('handel')
->and($categoryFetch->portal)->toBe('presseecho')
->and($categoryFetch->language)->toBe('de')
->and($categoryFetch->reader)->toBe('feedly')
->and($categoryFetch->subscribers)->toBe(12)
->and($categoryFetch->getAttributes())->not->toHaveKey('ip');
$portalFetch = FeedFetch::query()->where('feed', FeedFetch::FEED_PORTAL)->sole();
expect($portalFetch->feed_key)->toBeNull()
->and($portalFetch->reader)->toBeNull()
->and($portalFetch->subscribers)->toBeNull();
$newsroomFetch = FeedFetch::query()->where('feed', FeedFetch::FEED_NEWSROOM)->sole();
expect($newsroomFetch->feed_key)->toBe('nordwind')
->and($newsroomFetch->reader)->toBe('netnewswire');
});
test('the report counts fetches per reader with subscribers as the per-feed maximum, and the top feeds by name', function () {
/** @var TestCase $this */
feedLogCategory();
Company::factory()->presseecho()->create(['name' => 'Nordwind Energie AG', 'slug' => 'nordwind', 'is_active' => true]);
FeedFetch::factory()->category('handel')->reader('feedly', 12)->count(3)->create();
FeedFetch::factory()->category('handel')->reader('feedly', 14)->create();
FeedFetch::factory()->newsroom('nordwind')->reader('feedly', 2)->create();
FeedFetch::factory()->reader('inoreader', 5)->count(2)->create();
FeedFetch::factory()->reader(null)->create();
FeedFetch::factory()->reader('feedly', 99)->create(['created_at' => now()->subDays(40)]);
$report = app(FeedFetchReport::class);
$readers = collect($report->byReader(30))->keyBy('reader');
expect($readers->get('feedly')['fetches'])->toBe(5)
->and($readers->get('feedly')['feeds'])->toBe(2)
->and($readers->get('feedly')['subscribers'])->toBe(16)
->and($readers->get('inoreader')['fetches'])->toBe(2)
->and($readers->get('inoreader')['subscribers'])->toBe(5)
->and($readers->get('')['label'])->toBe('Sonstige')
->and($readers->get('')['subscribers'])->toBeNull();
$top = collect($report->topFeeds(30))->keyBy(fn (array $row) => $row['feed'].':'.$row['feed_key']);
expect($top->get('category:handel')['label'])->toBe('Presseecho · DE · Branche · Handel')
->and($top->get('category:handel')['fetches'])->toBe(4)
->and($top->get('category:handel')['subscribers'])->toBe(14)
->and($top->get('newsroom:nordwind')['label'])->toBe('Presseecho · DE · Newsroom · Nordwind Energie AG')
->and($top->get('portal:')['fetches'])->toBe(3)
->and($report->totals(30))->toMatchArray(['total' => 8, 'portal' => 3, 'category' => 4, 'newsroom' => 1, 'since_total' => 9]);
});
test('the delivery panel shows the fetch count per feed for the last 30 days', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create(['slug' => 'nordwind', 'is_active' => true]);
$customer->companies()->attach($company->id, ['role' => 'member']);
$category = feedLogCategory();
$release = feedLogRelease($category, $company, 'nordwind-neu');
FeedFetch::factory()->category('handel')->count(7)->create();
FeedFetch::factory()->newsroom('nordwind')->count(2)->create();
FeedFetch::factory()->count(1)->create();
FeedFetch::factory()->category('handel')->create(['portal' => 'businessportal24']);
FeedFetch::factory()->category('handel')->create(['created_at' => now()->subDays(45)]);
$feeds = collect(app(VisibilityStats::class)->releaseSummary($release)['feeds'])->keyBy('url');
expect($feeds->get('https://presseecho.test/de/category/handel/feed')['fetches_30d'])->toBe(7)
->and($feeds->get('https://presseecho.test/de/newsroom/nordwind/feed')['fetches_30d'])->toBe(2)
->and($feeds->get('https://presseecho.test/de/feed')['fetches_30d'])->toBe(1);
});
test('the purge command removes feed fetches older than the retention period', function () {
/** @var TestCase $this */
AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, '0');
FeedFetch::factory()->create(['created_at' => now()->subDays(400)]);
FeedFetch::factory()->create(['created_at' => now()->subDays(10)]);
$this->artisan(PurgePressReleaseEvents::class, ['--days' => 395])->assertSuccessful();
expect(FeedFetch::query()->count())->toBe(1);
});
test('the reader summary keeps feeds apart by portal and language', function () {
// Gleicher Reader, gleicher Schlüssel (Portalfeed) — auf zwei Portalen und
// in zwei Sprachen sind das drei Feeds mit drei Abonnentenzahlen.
FeedFetch::factory()->reader('feedly', 12)->count(2)->create();
FeedFetch::factory()->reader('feedly', 20)->create(['portal' => 'businessportal24']);
FeedFetch::factory()->reader('feedly', 3)->create(['language' => 'en']);
$feedly = collect(app(FeedFetchReport::class)->byReader(30))->firstWhere('reader', 'feedly');
expect($feedly['fetches'])->toBe(4)
->and($feedly['feeds'])->toBe(3)
->and($feedly['subscribers'])->toBe(35);
});
test('the report groups and limits in sql: one query for top feeds and for the per-release counts', function () {
/** @var TestCase $this */
FeedFetch::factory()->category('handel')->reader('feedly', 12)->count(3)->create();
FeedFetch::factory()->category('handel')->reader(null)->create();
FeedFetch::factory()->newsroom('nordwind')->reader('feedly', 2)->create();
FeedFetch::factory()->reader('inoreader', 5)->count(2)->create();
FeedFetch::factory()->reader('inoreader', 5)->create(['language' => 'en']);
FeedFetch::factory()->category('handel')->create(['created_at' => now()->subDays(40)]);
$report = app(FeedFetchReport::class);
DB::enableQueryLog();
$top = $report->topFeeds(30, limit: 2);
$feedQueries = collect(DB::getQueryLog())->pluck('query')->filter(fn (string $sql): bool => str_contains($sql, 'feed_fetches'));
// Umsetzungsaudit 29.09.2026, OPT-1.
expect($feedQueries)->toHaveCount(1)
->and(strtolower((string) $feedQueries->first()))->toContain('limit')
->and(collect($top)->pluck('fetches')->all())->toBe([4, 2])
->and($top[0]['readers'])->toBe(2)
->and($top[0]['subscribers'])->toBe(12);
DB::flushQueryLog();
$counts = $report->fetchesFor([
['feed' => FeedFetch::FEED_CATEGORY, 'feed_key' => 'handel', 'portal' => 'presseecho', 'language' => 'de'],
['feed' => FeedFetch::FEED_NEWSROOM, 'feed_key' => 'nordwind', 'portal' => 'presseecho', 'language' => 'de'],
['feed' => FeedFetch::FEED_PORTAL, 'feed_key' => null, 'portal' => 'presseecho', 'language' => 'de'],
['feed' => FeedFetch::FEED_PORTAL, 'feed_key' => null, 'portal' => 'businessportal24', 'language' => 'de'],
]);
expect(DB::getQueryLog())->toHaveCount(1)
->and($counts)->toBe([
'category:handel:presseecho:de' => 4,
'newsroom:nordwind:presseecho:de' => 1,
'portal::presseecho:de' => 2,
'portal::businessportal24:de' => 0,
]);
});