presseportale/tests/Feature/Web/FeedTest.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

239 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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)
->assertSee('https://presseecho.test/de/newsroom/{slug}/feed', false)
->assertSee('https://presseecho.test/de/rss', false);
});
test('the hub redirects feed addresses to the portal', function () {
/** @var TestCase $this */
feedCategory(Portal::Both);
$company = Company::factory()->businessportal24()->create(['slug' => 'nordwind', 'is_active' => true]);
$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);
// Newsroom-Feed folgt dem Portal der Firma, nicht dem Standard-Portal.
$this->get('https://pressekonto.test/de/newsroom/nordwind/feed')
->assertRedirect('https://businessportal24.test/de/newsroom/nordwind/feed')
->assertStatus(301);
$this->get('https://pressekonto.test/de/newsroom/gibt-es-nicht/feed')->assertNotFound();
});
test('the newsroom feed lists the releases of one company on the own portal and follows the newsroom visibility', function () {
/** @var TestCase $this */
$category = feedCategory();
$company = Company::factory()->presseecho()->create(['name' => 'Nordwind Energie AG', 'slug' => 'nordwind', 'is_active' => true, 'boilerplate' => '<p>Nordwind baut Windparks.</p>']);
$other = Company::factory()->presseecho()->create(['slug' => 'andere', 'is_active' => true]);
$mine = fn (string $slug, array $attributes = []) => PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)
->create(['slug' => $slug, 'language' => 'de', 'published_at' => now()->subHour(), ...$attributes]);
$mine('nordwind-neu', ['title' => 'Nordwind eröffnet Windpark']);
$mine('nordwind-alt', ['published_at' => now()->subDays(2)]);
$mine('nordwind-en', ['language' => 'en']);
PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($other)->create(['slug' => 'andere-firma', 'language' => 'de', 'published_at' => now()->subHour()]);
$response = $this->get('https://presseecho.test/de/newsroom/nordwind/feed')->assertSuccessful();
$xml = (string) $response->getContent();
expect($xml)
->toContain('<title>Pressemitteilungen von Nordwind Energie AG – presseecho</title>')
->toContain('<link>https://presseecho.test/de/newsroom/nordwind</link>')
->toContain('<atom:link href="https://presseecho.test/de/newsroom/nordwind/feed" rel="self" type="application/rss+xml" />')
->toContain('<description>Nordwind baut Windparks.</description>')
->toContain('press-release/nordwind-neu')
->toContain('press-release/nordwind-alt');
$this->assertStringNotContainsString('andere-firma', $xml);
$this->assertStringNotContainsString('nordwind-en', $xml);
// Ohne veröffentlichte Meldung keine öffentliche Seite — und kein Feed.
$this->get('https://presseecho.test/de/newsroom/andere/feed')->assertSuccessful();
$empty = Company::factory()->presseecho()->create(['slug' => 'leer', 'is_active' => true]);
$this->get('https://presseecho.test/de/newsroom/leer/feed')->assertNotFound();
// Deaktiviert oder entfernt: sofort weg, auch mit warmem Cache.
$company->update(['is_active' => false]);
$this->get('https://presseecho.test/de/newsroom/nordwind/feed')->assertNotFound();
$company->update(['is_active' => true, 'gone_at' => now()]);
$this->get('https://presseecho.test/de/newsroom/nordwind/feed')->assertNotFound();
});
test('the newsroom page announces its feed for autodiscovery and links it', function () {
/** @var TestCase $this */
$category = feedCategory();
$company = Company::factory()->presseecho()->create(['name' => 'Nordwind Energie AG', 'slug' => 'nordwind', 'is_active' => true]);
PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)->create(['slug' => 'nordwind-neu', 'language' => 'de', 'published_at' => now()->subHour()]);
$this->get('https://presseecho.test/de/newsroom/nordwind')->assertSuccessful()
->assertSee('<link rel="alternate" type="application/rss+xml" title="RSS-Feed: Pressemitteilungen von Nordwind Energie AG – presseecho" href="https://presseecho.test/de/newsroom/nordwind/feed">', false)
->assertSee('href="https://presseecho.test/de/newsroom/nordwind/feed" type="application/rss+xml"', false)
->assertSeeText('RSS-Feed');
});
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', 'category', 'handel']);
expect(Cache::has($key))->toBeTrue();
WebCaches::forgetPublicPages();
expect(Cache::has(WebPageCache::key('feed', ['presseecho', 'de', 'category', 'handel'])))->toBeFalse();
});
test('the feed teaser decodes stored entities before escaping them once', function () {
/** @var TestCase $this */
$category = feedCategory();
feedRelease($category, 'entities', ['text' => '<p>Müller &amp; Söhne &quot;neu&quot; &ndash; jetzt</p>']);
$xml = (string) $this->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful()->getContent();
expect($xml)->toContain('<description>Müller &amp; Söhne &quot;neu&quot; – jetzt</description>')
->not->toContain('&amp;amp;')
->not->toContain('&amp;quot;');
});