presseportale/tests/Feature/Web/NewsroomPageTest.php
Kevin Adametz 72432fdb82 web/api/ki: Soft Deletes wieder wirksam, Caches bei Firmenwechsel, KI-Ergebnis an Inhalt gebunden (Audit Block 3+4, Deploy D)
Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben

F04: withoutGlobalScopes() ohne Argument entfernte neben dem PortalScope
auch den Soft-Delete-Filter. Öffentliche, Kunden-, API-, Vorschau- und
Medienpfade sowie die PressRelease-Helfer schreiben jetzt
withoutGlobalScope(PortalScope::class). Zwei Stellen, die der Audit nicht
kannte: die öffentliche Meldungsseite zeigte gelöschte Firmenkontakte mit
Name, Mail und Telefon; das Pressefach listete gelöschte Kontakte weiter
und ließ sie bearbeiten. Ein Architekturtest friert die 39 bewusst
verbliebenen Stellen (Admin, Importer, Befehle, Slug-Eindeutigkeit) ein.
Der Vorschau-Test prüfte bisher forceDelete statt delete.

F22: LOWER(email) machte auf companies (110k Zeilen) bei jedem Login einen
Vollscan; die Kollation ist ohnehin case-insensitiv. EmailQuery vergleicht
auf MySQL direkt über den Index, auf SQLite (Tests) weiter per LOWER.
Neuer Index contacts_email_idx.

F13: Firmen-Observer verwirft die öffentlichen Seiten-Caches bei
Löschung, Wiederherstellung und is_active-Wechsel auf jedem Weg; die
Newsroom-Seite prüft Sichtbarkeit vor dem Cache-Treffer; Kontakt-
Änderungen im Pressefach flushen. Der Tombstone-Test wärmte den Cache
vorher nicht und traf den Fehler nie.

F12: Gemeldete URLs werden über Host, Ausgabe und Slug aufgelöst
(ReportedUrlResolver); fremde Hosts zählen nicht, bei Doppelveröffentlichung
gewinnt die Erstquelle.

F11: per_page 1–100, status als Enum, language nur de/en, Kategorie muss
aktiv sein und zum Ziel-Portal passen. Legacy-Aliasse behalten ihren
Vertrag.

F23: Rückgabewert von put() wird geprüft; Logo erst speichern, dann
Datenbank, dann altes Logo löschen. Fehler enden als Feldfehler, nicht
als verlorenes Logo.

Block 4 — KI-Prüfung und Scheduler

F07: Klassifikation und Score tragen die Prüfsumme des Inhalts bei
Einplanung. Weicht sie beim Speichern ab, wird das Ergebnis verworfen
(ki_audits.discarded_at) und ein neuer Lauf eingeplant. Der Scheduler
veröffentlicht keine Meldung, deren jüngste Klassifikation nicht zum
aktuellen Text passt, sondern plant eine Prüfung mit Routing ein.

F18: Kontingent an den Kalendermonat gebunden (users.press_release_quota_period).
Verbrauch aus einem früheren Monat zählt null, auch ohne Reset-Cron; der
Reset ist idempotent und lässt den laufenden Monat in Ruhe.

F20: Der Publikations-Scheduler liefert bei technischen Fehlern einen
Fehler-Exit-Code, damit der Failure-Hook greift.

F17: KI-Jobs mit $timeout = 120, retry_after 150; Runbook ergänzt.

F19: Aggregation der Aufrufe unter einem Cache-Lock für alle Einstiege.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:40:48 +00:00

238 lines
9.1 KiB
PHP

<?php
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Services\PressRelease\PressReleaseService;
use Illuminate\Support\Str;
use Tests\TestCase;
function makeNewsroom(string $name, Portal $portal = Portal::Businessportal24, array $attributes = []): Company
{
return Company::factory()
->state(['portal' => $portal->value])
->create(array_merge([
'name' => $name,
'slug' => Str::slug($name).'-'.fake()->unique()->numberBetween(1000, 9999),
'is_active' => true,
], $attributes));
}
function publishForCompany(Company $company, string $title, array $attributes = [], ?Category $category = null): PressRelease
{
$category ??= Category::factory()->create(['portal' => Portal::Both]);
return PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->for($category)
->for($company)
->create(array_merge([
'title' => $title,
'slug' => Str::slug($title).'-'.fake()->unique()->numberBetween(1000, 9999),
'language' => 'de',
'published_at' => now()->subDay(),
], $attributes));
}
test('the newsrooms index renders the editorial shell and lists active newsrooms', function () {
/** @var TestCase $this */
$company = makeNewsroom('Nordwind Energie AG');
publishForCompany($company, 'Nordwind nimmt neuen Windpark in Betrieb');
$this->get('https://businessportal24.test/de/newsrooms')
->assertSuccessful()
->assertSee('businessportal', false)
->assertSeeText('Newsrooms')
->assertSeeText('Nordwind Energie AG');
});
test('the newsrooms index filters by company name', function () {
/** @var TestCase $this */
$match = makeNewsroom('Solartec Industries GmbH');
publishForCompany($match, 'Solartec meldet Rekordquartal');
$other = makeNewsroom('Hafenlogistik Bremen AG');
publishForCompany($other, 'Hafenlogistik baut Terminal aus');
$this->get('https://businessportal24.test/de/newsrooms?q=Solartec')
->assertSuccessful()
->assertSeeText('Solartec Industries GmbH')
->assertDontSeeText('Hafenlogistik Bremen AG');
});
test('the newsrooms index excludes companies without published releases', function () {
/** @var TestCase $this */
makeNewsroom('Stilles Unternehmen AG');
$this->get('https://businessportal24.test/de/newsrooms')
->assertSuccessful()
->assertDontSeeText('Stilles Unternehmen AG');
});
test('the newsroom detail page renders company data and its releases', function () {
/** @var TestCase $this */
$company = makeNewsroom('Bavaria Maschinenbau AG', attributes: [
'boilerplate' => 'Bavaria Maschinenbau ist ein führender Anbieter von Industrielösungen.',
]);
publishForCompany($company, 'Bavaria präsentiert neue Produktionslinie');
$this->get('https://businessportal24.test/de/newsroom/'.$company->slug)
->assertSuccessful()
->assertSeeText('Bavaria Maschinenbau AG')
->assertSeeText('Bavaria präsentiert neue Produktionslinie')
->assertSeeText('Alle Meldungen');
});
test('the newsroom detail page links the company website with an absolute url', function () {
/** @var TestCase $this */
// Bestandsdaten ohne Schema („www.akgsoftware.de“) — als roher href
// würde daraus der relative Link /de/newsroom/www.akgsoftware.de.
$company = makeNewsroom('AKG Software Consulting GmbH', attributes: [
'website' => 'www.akgsoftware.de',
]);
publishForCompany($company, 'AKG veröffentlicht neue Version');
$this->get('https://businessportal24.test/de/newsroom/'.$company->slug)
->assertSuccessful()
->assertSee('href="https://www.akgsoftware.de"', false)
->assertDontSee('newsroom/www.akgsoftware.de', false);
// Hinterlegte URLs mit Schema bleiben unverändert.
expect(Company::factory()->make(['website' => 'https://akgsoftware.de'])->websiteUrl())
->toBe('https://akgsoftware.de')
->and(Company::factory()->make(['website' => null])->websiteUrl())->toBeNull();
});
test('the newsroom detail page returns 404 for an unknown slug', function () {
/** @var TestCase $this */
$this->get('https://businessportal24.test/de/newsroom/gibt-es-nicht')
->assertNotFound();
});
test('the newsroom detail page returns 404 when the company has no published releases', function () {
/** @var TestCase $this */
$company = makeNewsroom('Leeres Newsroom AG');
$this->get('https://businessportal24.test/de/newsroom/'.$company->slug)
->assertNotFound();
});
test('the newsroom page disappears with its last published release, cache included', function () {
/** @var TestCase $this */
// Roadmap 1b.1/1b.5: öffentlich nur mit veröffentlichter Meldung — und
// der Rückbau greift sofort, nicht erst nach Ablauf des Seiten-Caches.
$company = makeNewsroom('Einmalig AG');
$release = publishForCompany($company, 'Die einzige Meldung');
$url = 'https://businessportal24.test/de/newsroom/'.$company->slug;
$this->get($url)->assertOk();
$this->get('https://businessportal24.test/de/newsrooms')->assertSeeText('Einmalig AG');
app(PressReleaseService::class)->archive($release);
$this->get($url)->assertNotFound();
$this->get('https://businessportal24.test/de/newsrooms')->assertDontSeeText('Einmalig AG');
});
test('an admin status change away from published takes the newsroom page down', function () {
/** @var TestCase $this */
$company = makeNewsroom('Rückzug GmbH');
$release = publishForCompany($company, 'Zurückgezogen');
$url = 'https://businessportal24.test/de/newsroom/'.$company->slug;
$this->get($url)->assertOk();
app(PressReleaseService::class)->changeStatusFromAdmin($release, PressReleaseStatus::Draft);
$this->get($url)->assertNotFound();
});
test('deleting the last published release from admin takes the newsroom page down', function () {
/** @var TestCase $this */
$company = makeNewsroom('Gelöscht KG');
$release = publishForCompany($company, 'Wird entfernt');
$url = 'https://businessportal24.test/de/newsroom/'.$company->slug;
$this->get($url)->assertOk();
app(PressReleaseService::class)->deleteFromAdmin($release);
$this->get($url)->assertNotFound();
});
test('the newsrooms pages are available per edition and portal', function () {
/** @var TestCase $this */
$this->get('https://businessportal24.test/en/newsrooms')->assertSuccessful();
$this->get('https://presseecho.test/de/newsrooms')
->assertSuccessful()
->assertSee('presse', false);
});
test('legacy newsroom html urls redirect to the clean edition path', function () {
/** @var TestCase $this */
$this->get('https://businessportal24.test/de/newsrooms.html')
->assertRedirect('https://businessportal24.test/de/newsrooms');
$this->get('https://businessportal24.test/de/newsroom/acme.html')
->assertRedirect('https://businessportal24.test/de/newsroom/acme');
});
test('soft-deleting a company takes a cached newsroom page down immediately', function () {
/** @var TestCase $this */
$company = makeNewsroom('Aufgelöst GmbH');
publishForCompany($company, 'Letzte Meldung vor der Löschung');
$url = 'https://businessportal24.test/de/newsroom/'.$company->slug;
// Erst aufwärmen: ohne warmen Seiten-Cache prüft der Test nur den
// Neuaufbau, nicht das Verhalten beim Cache-Treffer (F13).
$this->get($url)->assertOk();
$company->delete();
$this->get($url)->assertNotFound();
});
test('deactivating a company takes a cached newsroom page down immediately', function () {
/** @var TestCase $this */
$company = makeNewsroom('Ruhend AG');
publishForCompany($company, 'Letzte Meldung vor der Deaktivierung');
$url = 'https://businessportal24.test/de/newsroom/'.$company->slug;
$this->get($url)->assertOk();
$company->update(['is_active' => false]);
$this->get($url)->assertNotFound();
});
test('a deactivated company beats a warm newsroom page cache even without a flush', function () {
/** @var TestCase $this */
$company = makeNewsroom('Still deaktiviert GmbH');
publishForCompany($company, 'Meldung im Cache');
$url = 'https://businessportal24.test/de/newsroom/'.$company->slug;
$this->get($url)->assertOk();
// Ohne Model-Events, also ohne Observer-Flush: So prüft der Test die
// Sichtbarkeitsprüfung VOR dem Cache-Treffer in NewsroomDetailPageData.
$company->forceFill(['is_active' => false])->saveQuietly();
$this->get($url)->assertNotFound();
});
test('a soft-deleted company beats a warm newsroom page cache even without a flush', function () {
/** @var TestCase $this */
$company = makeNewsroom('Still gelöscht GmbH');
publishForCompany($company, 'Meldung im Cache');
$url = 'https://businessportal24.test/de/newsroom/'.$company->slug;
$this->get($url)->assertOk();
// Query-Builder statt delete(): keine Events, kein Flush.
Company::query()->withoutGlobalScopes()->whereKey($company->id)->update(['deleted_at' => now()]);
$this->get($url)->assertNotFound();
});