- 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>
204 lines
11 KiB
PHP
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,
|
|
]);
|
|
});
|