web: RSS-Feeds je Branche und je Portal (Roadmap 3.1); dev: Sichtung des Ordners, Protokoll-Teile umnummeriert

- /{edition}/feed und /{edition}/category/{slug}/feed (FeedController,
  RSS 2.0 mit atom:link self, dc:creator, category; jüngste 50 der Ausgabe,
  Branche inkl. aktiver Unterkategorien, nur visibleInArchive).
- Cache über WebPageCache (Key-Gruppe feed), Hub leitet per 301 aufs Portal
  (canonical.portal_only_content_routes).
- Autodiscovery über @yield('feed_links') auf Start-, Branchen- und
  Archivseiten, sichtbarer Link in der Archiv-Leiste, llms.txt nennt die
  Feed-Muster, Block „RSS-Feeds" im Panel „Auslieferung"
  (VisibilityStats::feedLinks).
- Übersetzungen unter web.rss (web.feed ist die Startseiten-Liste).
- Tests: FeedTest (7), VisibilityStatsTest erweitert.
- dev/2026-09-01: Sichtung 18.09. (Teil 11), zweite Teil-Serie des
  Protokolls jetzt 7–10 mit nachgezogenen Verweisen, Anhang 1 abgeglichen,
  E6 abgehakt, 2.6 extern, 1c.5/E8 wartet auf Daten, 3.1 abgehakt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-18 13:57:09 +00:00
parent e2ef4dee31
commit 9d5e5a0b54
20 changed files with 547 additions and 28 deletions

View file

@ -3,6 +3,7 @@
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;
@ -108,7 +109,9 @@ test('the daily series is gapless, marks days before measurement as unmeasured a
test('the release summary reports sitemap presence only for public releases and lists crawler fetches', function () {
/** @var TestCase $this */
[, $company] = claimant();
$public = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'published_at' => now()->subDay(), 'hits' => 9]);
$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']);
@ -126,12 +129,18 @@ test('the release summary reports sitemap presence only for public releases and
->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();
->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/feed',
]);
$draftSummary = $stats->releaseSummary($draft);
expect($draftSummary['in_sitemap_since'])->toBeNull()
->and($draftSummary['crawlers'])->toBe([]);
->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 () {
@ -154,6 +163,8 @@ test('the release page shows the delivery panel with daily bars, sitemap date an
->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)

View file

@ -0,0 +1,168 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Support\WebCaches;
use App\Support\WebPageCache;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
/**
* RSS-Feeds je Branche und je Portal (Roadmap 3.1): /{edition}/feed und
* /{edition}/category/{slug}/feed.
*/
function feedCategory(Portal $portal = Portal::Presseecho, string $slug = 'handel', string $name = 'Handel', ?Category $parent = null): Category
{
$category = Category::factory()->create(['portal' => $portal, 'is_active' => true, 'parent_id' => $parent?->id]);
$category->translations()->create(['locale' => 'de', 'name' => $name, 'slug' => $slug, 'description' => 'Meldungen aus dem Bereich '.$name.'.']);
$category->translations()->create(['locale' => 'en', 'name' => $name.' EN', 'slug' => $slug.'-en']);
return $category;
}
function feedRelease(Category $category, string $slug, array $attributes = [], Portal $portal = Portal::Presseecho): PressRelease
{
return PressRelease::factory()
->published()
->forPortal($portal)
->for($category)
->for(Company::factory()->presseecho()->create(['name' => 'Feed GmbH & Co.']))
->create(['slug' => $slug, 'published_at' => now()->subHour(), 'language' => 'de', ...$attributes]);
}
test('the category feed lists the newest releases of the category and its children on the own portal', function () {
/** @var TestCase $this */
$category = feedCategory();
$child = feedCategory(Portal::Presseecho, 'einzelhandel', 'Einzelhandel', $category);
$other = feedCategory(Portal::Presseecho, 'energie', 'Energie');
feedRelease($category, 'neu', ['title' => 'Neue Meldung & mehr', 'text' => '<p>Erster Absatz der Meldung.</p>', 'published_at' => now()->subHour()]);
feedRelease($child, 'kind', ['title' => 'Aus der Unterkategorie', 'published_at' => now()->subHours(2)]);
feedRelease($other, 'andere-branche');
feedRelease($category, 'fremdes-portal', [], Portal::Businessportal24);
feedRelease($category, 'terminiert', ['published_at' => now()->addHour()]);
feedRelease($category, 'englisch', ['language' => 'en']);
feedRelease($category, 'beide-portale', ['published_at' => now()->subHours(3)], Portal::Both);
$response = $this->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful();
expect($response->headers->get('Content-Type'))->toBe('application/rss+xml; charset=UTF-8');
$xml = (string) $response->getContent();
expect($xml)
->toStartWith('<?xml version="1.0" encoding="UTF-8"?>')
->toContain('<title>Pressemitteilungen Handel – presseecho</title>')
->toContain('<link>https://presseecho.test/de/category/handel</link>')
->toContain('<atom:link href="https://presseecho.test/de/category/handel/feed" rel="self" type="application/rss+xml" />')
->toContain('<description>Meldungen aus dem Bereich Handel.</description>')
->toContain('<language>de</language>')
->toContain('<title>Neue Meldung &amp; mehr</title>')
->toContain('<guid isPermaLink="true">https://presseecho.test/de/press-release/neu</guid>')
->toContain('<description>Erster Absatz der Meldung.</description>')
->toContain('<category>Handel</category>')
->toContain('<dc:creator>Feed GmbH &amp; Co.</dc:creator>')
->toContain('<link>https://presseecho.test/de/press-release/kind</link>')
->toContain('<link>https://presseecho.test/de/press-release/beide-portale</link>');
// Neueste zuerst.
expect(strpos($xml, 'press-release/neu'))->toBeLessThan((int) strpos($xml, 'press-release/kind'));
foreach (['andere-branche', 'fremdes-portal', 'terminiert', 'press-release/englisch'] as $absent) {
$this->assertStringNotContainsString($absent, $xml);
}
// Wohlgeformt: die Ausgabe muss ein XML-Parser lesen können.
$document = simplexml_load_string($xml);
expect($document)->not->toBeFalse()
->and(count($document->channel->item))->toBe(3);
});
test('the portal feed lists releases of all categories, newest first, capped', function () {
/** @var TestCase $this */
$handel = feedCategory();
$energie = feedCategory(Portal::Presseecho, 'energie', 'Energie');
feedRelease($handel, 'handel-neu', ['published_at' => now()->subHour()]);
feedRelease($energie, 'energie-alt', ['published_at' => now()->subDay()]);
feedRelease($handel, 'fremd', [], Portal::Businessportal24);
$xml = (string) $this->get('https://presseecho.test/de/feed')->assertSuccessful()->getContent();
expect($xml)
->toContain('<title>Pressemitteilungen – presseecho</title>')
->toContain('<link>https://presseecho.test/de</link>')
->toContain('<atom:link href="https://presseecho.test/de/feed" rel="self" type="application/rss+xml" />')
->toContain('press-release/handel-neu')
->toContain('press-release/energie-alt');
expect(strpos($xml, 'handel-neu'))->toBeLessThan((int) strpos($xml, 'energie-alt'));
$this->assertStringNotContainsString('press-release/fremd', $xml);
});
test('an unknown or inactive category has no feed', function () {
/** @var TestCase $this */
$inactive = feedCategory(Portal::Presseecho, 'veraltet', 'Veraltet');
$inactive->update(['is_active' => false]);
$this->get('https://presseecho.test/de/category/gibt-es-nicht/feed')->assertNotFound();
$this->get('https://presseecho.test/de/category/veraltet/feed')->assertNotFound();
});
test('the english edition serves the feed under the english slug with english releases only', function () {
/** @var TestCase $this */
$category = feedCategory();
feedRelease($category, 'deutsch');
feedRelease($category, 'english', ['language' => 'en', 'title' => 'English release']);
$xml = (string) $this->get('https://presseecho.test/en/category/handel-en/feed')->assertSuccessful()->getContent();
expect($xml)
->toContain('<title>Handel EN press releases – presseecho</title>')
->toContain('<language>en</language>')
->toContain('https://presseecho.test/en/press-release/english');
$this->assertStringNotContainsString('press-release/deutsch', $xml);
});
test('category and home pages announce the feeds for autodiscovery and link them', function () {
/** @var TestCase $this */
$category = feedCategory();
feedRelease($category, 'irgendwas');
$this->get('https://presseecho.test/de/category/handel')->assertSuccessful()
->assertSee('<link rel="alternate" type="application/rss+xml" title="RSS-Feed: Pressemitteilungen Handel – presseecho" href="https://presseecho.test/de/category/handel/feed">', false)
->assertSee('<link rel="alternate" type="application/rss+xml" title="RSS-Feed: alle Pressemitteilungen – presseecho" href="https://presseecho.test/de/feed">', false)
->assertSee('href="https://presseecho.test/de/category/handel/feed" type="application/rss+xml"', false);
$this->get('https://presseecho.test/de')->assertSuccessful()
->assertSee('type="application/rss+xml" title="RSS-Feed: alle Pressemitteilungen – presseecho" href="https://presseecho.test/de/feed"', false);
$this->get('https://presseecho.test/llms.txt')->assertSuccessful()
->assertSee('https://presseecho.test/de/feed', false)
->assertSee('https://presseecho.test/de/category/{slug}/feed', false);
});
test('the hub redirects feed addresses to the portal', function () {
/** @var TestCase $this */
feedCategory(Portal::Both);
$this->get('https://pressekonto.test/de/feed')
->assertRedirect('https://presseecho.test/de/feed')
->assertStatus(301);
$this->get('https://pressekonto.test/de/category/handel/feed')
->assertRedirect('https://presseecho.test/de/category/handel/feed')
->assertStatus(301);
});
test('the feed is cached with the page data and cleared with the public pages', function () {
/** @var TestCase $this */
$category = feedCategory();
feedRelease($category, 'erste');
$this->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful();
$key = WebPageCache::key('feed', ['presseecho', 'de', 'handel']);
expect(Cache::has($key))->toBeTrue();
WebCaches::forgetPublicPages();
expect(Cache::has(WebPageCache::key('feed', ['presseecho', 'de', 'handel'])))->toBeFalse();
});