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, ]); });