- 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>
239 lines
13 KiB
PHP
239 lines
13 KiB
PHP
<?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 & 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 & 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 & Söhne "neu" – jetzt</p>']);
|
||
|
||
$xml = (string) $this->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful()->getContent();
|
||
|
||
expect($xml)->toContain('<description>Müller & Söhne "neu" – jetzt</description>')
|
||
->not->toContain('&amp;')
|
||
->not->toContain('&quot;');
|
||
});
|