web: Portal-Inhalte sind auf dem Hub nicht mehr erreichbar (Anhang 2, E10)

Kategorien, Jahresarchive, Meldungen und Newsrooms liefen auch unter
pressekonto.com — die Ausgabe-Routen gelten für alle Hosts, und der
Portal-Kontext fällt dort auf businessportal24 zurück. Jetzt 301 auf das
Portal.

Meldung und Newsroom folgen ihrem eigenen Portal, nicht einer festen
Vorgabe; bei Doppelveröffentlichung (gleicher Slug auf beiden Portalen)
gewinnt die Erstquelle, sonst endete die Weiterleitung auf der Kopie.
Kategorie, Archiv und Suche gibt es auf beiden Portalen — dort entscheidet
canonical.portal_only_host.

Bewusst nicht weitergeleitet wird ein unbekannter Slug (sonst 301 in ein
404) und alles, was kein GET ist (ein 301 auf POST verliert die Anfrage —
Newsroom beanspruchen und Share-Zählung laufen weiter). Query-Parameter
bleiben erhalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-18 09:15:04 +00:00
parent 5dcaf4e7b0
commit edb2d49199
5 changed files with 222 additions and 28 deletions

View file

@ -2,6 +2,9 @@
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Services\PressRelease\CanonicalAssigner;
use Tests\TestCase;
test('portal-only pages redirect from the hub to the leading portal', function (string $path) {
@ -60,15 +63,113 @@ test('the retired contact and about pages redirect to the home page in one hop',
['https://presseecho.test', 'https://presseecho.test/de'],
]);
test('portal content served through the hub keeps the portal as canonical', function () {
test('portal content keeps its own portal as canonical', function () {
/** @var TestCase $this */
// Die Ausgabe-Routen gelten für alle Hosts, deshalb sind Kategorien und
// Meldungen auch unter pressekonto.com erreichbar. Sie rendern dort das
// Portal — der Canonical muss dorthin zeigen, nie auf den Hub.
// Sicherheitsnetz hinter der Weiterleitung: Portal-Inhalte, die über den
// Hub-Kontext gerendert werden, dürfen niemals pressekonto als Canonical
// tragen (`webPortalContextApplied`).
$category = Category::factory()->create(['portal' => Portal::Both]);
$category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']);
$this->get('https://pressekonto.test/de/category/handel')->assertSuccessful()
->assertSee('<link rel="canonical" href="https://businessportal24.test/de/category/handel">', false)
->assertDontSee('<link rel="canonical" href="https://pressekonto.test/de/category/handel">', false);
$this->get('https://businessportal24.test/de/category/handel')->assertSuccessful()
->assertSee('<link rel="canonical" href="https://businessportal24.test/de/category/handel">', false);
$this->get('https://presseecho.test/de/category/handel')->assertSuccessful()
->assertSee('<link rel="canonical" href="https://presseecho.test/de/category/handel">', false);
});
test('portal content is no longer reachable on the hub and lands on its own portal', function () {
/** @var TestCase $this */
$category = Category::factory()->create(['portal' => Portal::Both]);
$category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']);
$company = Company::factory()->businessportal24()->create(['slug' => 'muster-gmbh']);
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->for($category)->for($company)
->create(['slug' => 'hub-umleitung-pm', 'published_at' => now()->subDay()]);
// Meldung und Newsroom folgen ihrem eigenen Portal.
$this->get('https://pressekonto.test/de/press-release/hub-umleitung-pm')
->assertStatus(301)->assertRedirect('https://businessportal24.test/de/press-release/hub-umleitung-pm');
$this->get('https://pressekonto.test/de/newsroom/muster-gmbh')
->assertStatus(301)->assertRedirect('https://businessportal24.test/de/newsroom/muster-gmbh');
$this->get('https://pressekonto.test/de/newsroom/muster-gmbh/verwalten')
->assertStatus(301)->assertRedirect('https://businessportal24.test/de/newsroom/muster-gmbh/verwalten');
// Kategorie und Jahresarchiv gibt es auf beiden Portalen: Standard-Portal,
// Query-Parameter bleiben erhalten.
$this->get('https://pressekonto.test/de/category/handel')
->assertStatus(301)->assertRedirect('https://presseecho.test/de/category/handel');
$this->get('https://pressekonto.test/de/category/handel/archiv/'.now()->year)
->assertStatus(301)->assertRedirect('https://presseecho.test/de/category/handel/archiv/'.now()->year);
$this->get('https://pressekonto.test/de/suche?q=muster')
->assertStatus(301)->assertRedirect('https://presseecho.test/de/suche?q=muster');
// Presseecho-Meldung geht auf presseecho, nicht auf das Standard-Portal des Hubs.
PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)
->for(Company::factory()->create(['portal' => Portal::Presseecho]))
->create(['slug' => 'nur-presseecho-pm', 'published_at' => now()->subDay()]);
$this->get('https://pressekonto.test/de/press-release/nur-presseecho-pm')
->assertStatus(301)->assertRedirect('https://presseecho.test/de/press-release/nur-presseecho-pm');
// Die Portale liefern weiterhin selbst aus.
$this->get('https://businessportal24.test/de/press-release/hub-umleitung-pm')->assertSuccessful();
$this->get('https://presseecho.test/de/category/handel')->assertSuccessful();
expect($release->fresh())->not->toBeNull();
});
test('a duplicate release redirects to the portal that holds the original', function () {
/** @var TestCase $this */
// Beide Fassungen tragen denselben Slug (so legt die API sie an) — die
// Weiterleitung darf nicht auf der Kopie landen.
config()->set('canonical.enabled', true);
config()->set('canonical.portal_by_category', ['it' => 'businessportal24']);
$category = Category::factory()->create(['portal' => Portal::Both]);
$category->translations()->create(['locale' => 'de', 'name' => 'IT', 'slug' => 'it']);
$company = Company::factory()->create(['portal' => Portal::Both]);
$text = '<p>Die Doppel GmbH eröffnet einen neuen Standort.</p>';
$copy = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)
->create(['slug' => 'doppel-standort', 'title' => 'Neuer Standort', 'text' => $text, 'published_at' => now()->subHours(2)]);
$source = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->for($category)->for($company)
->create(['slug' => 'doppel-standort', 'title' => 'Neuer Standort', 'text' => $text, 'published_at' => now()->subHour()]);
app(CanonicalAssigner::class)->assignFor($source);
expect($copy->fresh()->canonical_press_release_id)->toBe($source->id);
$this->get('https://pressekonto.test/de/press-release/doppel-standort')
->assertStatus(301)
->assertRedirect('https://businessportal24.test/de/press-release/doppel-standort');
});
test('an unknown slug on the hub stays a 404 instead of bouncing to the portal', function () {
/** @var TestCase $this */
$this->get('https://pressekonto.test/de/press-release/gibt-es-nicht')->assertNotFound();
$this->get('https://pressekonto.test/de/newsroom/gibt-es-nicht')->assertNotFound();
});
test('a removed release keeps its tombstone on the portal', function () {
/** @var TestCase $this */
$category = Category::factory()->create(['portal' => Portal::Both]);
$category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']);
PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->for($category)
->for(Company::factory()->businessportal24()->create())
->create(['slug' => 'entfernte-pm', 'published_at' => now()->subDay(), 'gone_at' => now()]);
$this->get('https://pressekonto.test/de/press-release/entfernte-pm')
->assertStatus(301)->assertRedirect('https://businessportal24.test/de/press-release/entfernte-pm');
});
test('posting to a portal route on the hub is not turned into a redirect', function () {
/** @var TestCase $this */
$company = Company::factory()->businessportal24()->create(['slug' => 'post-gmbh', 'email' => 'presse@post.example']);
// 301 auf ein POST würde die Anfrage verlieren — die Route bleibt.
$response = $this->post('https://pressekonto.test/de/newsroom/post-gmbh/verwalten', []);
expect($response->status())->not->toBe(301)
->and($company->fresh())->not->toBeNull();
});