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>
This commit is contained in:
Kevin Adametz 2026-09-18 10:40:48 +00:00
parent 62202a4b13
commit f235156de9
73 changed files with 1746 additions and 170 deletions

View file

@ -0,0 +1,106 @@
<?php
use App\Models\Company;
use App\Models\User;
use App\Services\Image\ImageService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
/**
* Logo-Tausch: erst die neue Datei speichern, dann die DB-Referenz wechseln,
* dann erst das alte Logo löschen. Ein Storage-Fehler darf weder Erfolg
* vortäuschen noch das bisherige Logo kosten (F23).
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
function failingPublicDisk(): FilesystemAdapter
{
$disk = Storage::fake('public');
// Proxy auf den echten Fake: nur put() schlägt fehl, alles andere —
// exists(), delete(), Livewires Temp-Upload — läuft echt.
$failingDisk = Mockery::mock($disk);
$failingDisk->shouldReceive('put')->andReturn(false);
Storage::set('public', $failingDisk);
return $disk;
}
test('the image service refuses to report a logo that was not written', function () {
/** @var TestCase $this */
failingPublicDisk();
expect(fn () => app(ImageService::class)->storeCompanyLogo(UploadedFile::fake()->image('logo.png', 400, 400), 'presseecho', 1))
->toThrow(RuntimeException::class, 'Das Logo konnte nicht gespeichert werden.');
});
test('the image service refuses to report a press release image that was not written', function () {
/** @var TestCase $this */
failingPublicDisk();
expect(fn () => app(ImageService::class)->storePressReleaseImage(UploadedFile::fake()->image('bild.jpg', 800, 600), 1, 'public'))
->toThrow(RuntimeException::class, 'Das Bild konnte nicht gespeichert werden.');
});
test('a failed logo write in the admin form keeps the previous logo and reports a validation error', function () {
/** @var TestCase $this */
$disk = failingPublicDisk();
$disk->put('company-logos/presseecho/9/alt.png', 'alt');
$disk->put('company-logos/presseecho/9/alt-sq.png', 'alt-sq');
$company = Company::factory()->presseecho()->create([
'logo_path' => 'company-logos/presseecho/9/alt.png',
'logo_variants' => ['sq' => 'company-logos/presseecho/9/alt-sq.png'],
]);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
LivewireVolt::actingAs($admin)
->test('admin.companies.edit', ['id' => $company->id])
->set('logo', UploadedFile::fake()->image('neu.png', 400, 400))
->call('update')
->assertHasErrors(['logo']);
$company->refresh();
expect($company->logo_path)->toBe('company-logos/presseecho/9/alt.png')
->and($company->logo_variants)->toBe(['sq' => 'company-logos/presseecho/9/alt-sq.png'])
->and($disk->exists('company-logos/presseecho/9/alt.png'))->toBeTrue()
->and($disk->exists('company-logos/presseecho/9/alt-sq.png'))->toBeTrue();
});
test('a successful logo replacement in the admin form removes the previous files only afterwards', function () {
/** @var TestCase $this */
$disk = Storage::fake('public');
$disk->put('company-logos/presseecho/9/alt.png', 'alt');
$disk->put('company-logos/presseecho/9/alt-sq.png', 'alt-sq');
$company = Company::factory()->presseecho()->create([
'logo_path' => 'company-logos/presseecho/9/alt.png',
'logo_variants' => ['sq' => 'company-logos/presseecho/9/alt-sq.png'],
]);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
LivewireVolt::actingAs($admin)
->test('admin.companies.edit', ['id' => $company->id])
->set('logo', UploadedFile::fake()->image('neu.png', 400, 400))
->call('update')
->assertHasNoErrors();
$company->refresh();
expect($company->logo_path)->not->toBe('company-logos/presseecho/9/alt.png')
->and($disk->exists((string) $company->logo_path))->toBeTrue()
->and($disk->exists('company-logos/presseecho/9/alt.png'))->toBeFalse()
->and($disk->exists('company-logos/presseecho/9/alt-sq.png'))->toBeFalse();
});

View file

@ -1,5 +1,6 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
@ -109,3 +110,97 @@ test('api user cannot access another users press release', function () {
$this->getJson("/api/v1/press-releases/{$pressRelease->id}")
->assertForbidden();
});
test('a soft deleted press release disappears from the api (Audit F04)', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$pressRelease = PressRelease::factory()->create(['user_id' => $user->id, 'title' => 'Bald geloescht']);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
$this->deleteJson("/api/v1/press-releases/{$pressRelease->id}")->assertNoContent();
$this->getJson('/api/v1/press-releases')->assertOk()->assertJsonMissing(['title' => 'Bald geloescht']);
$this->getJson("/api/v1/press-releases/{$pressRelease->id}")->assertForbidden();
$this->patchJson("/api/v1/press-releases/{$pressRelease->id}", ['title' => 'Wiederbelebt'])->assertForbidden();
expect(PressRelease::withoutGlobalScopes()->withTrashed()->find($pressRelease->id)?->title)->toBe('Bald geloescht');
});
test('a soft deleted company is neither listed nor usable for new press releases (Audit F04)', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$company = Company::factory()->presseecho()->create(['name' => 'Geloeschte Firma']);
$category = Category::factory()->withTranslations()->create();
$user->companies()->attach($company->id, ['role' => 'owner']);
$company->delete();
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']);
$this->getJson('/api/v1/companies')->assertOk()->assertJsonMissing(['name' => 'Geloeschte Firma']);
$this->getJson("/api/v1/companies/{$company->id}")->assertForbidden();
$this->postJson('/api/v1/press-releases', [
'company_id' => $company->id,
'category_id' => $category->id,
'language' => 'de',
'title' => 'Fuer eine geloeschte Firma',
'text' => 'Inhalt',
])->assertForbidden();
});
test('api list parameters are validated instead of passed through (Audit F11)', function (array $query) {
/** @var TestCase $this */
$user = User::factory()->create();
PressRelease::factory()->count(3)->create(['user_id' => $user->id]);
Sanctum::actingAs($user, ['press-releases:read', 'companies:read']);
$this->getJson('/api/v1/press-releases?'.http_build_query($query))->assertUnprocessable();
$this->getJson('/api/v1/companies?'.http_build_query(array_intersect_key($query, ['per_page' => true])) ?: '/api/v1/companies?per_page=-1')
->assertUnprocessable();
})->with([
'negative per_page' => [['per_page' => -1]],
'zero per_page' => [['per_page' => 0]],
'per_page above the cap' => [['per_page' => 101]],
'textual per_page' => [['per_page' => 'alle']],
'status as array' => [['status' => ['draft'], 'per_page' => -1]],
'unknown status' => [['status' => 'gibt-es-nicht', 'per_page' => -1]],
]);
test('api list accepts the documented parameter range and always limits the query (Audit F11)', function () {
/** @var TestCase $this */
$user = User::factory()->create();
PressRelease::factory()->count(3)->create(['user_id' => $user->id]);
Sanctum::actingAs($user, ['press-releases:read']);
$this->getJson('/api/v1/press-releases?per_page=1')->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('meta.per_page', 1);
$this->getJson('/api/v1/press-releases?per_page=100&status=draft')->assertOk()->assertJsonCount(3, 'data');
});
test('api press release input rejects unsupported languages and inactive or foreign categories (Audit F11)', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$company = Company::factory()->presseecho()->create();
$user->companies()->attach($company->id, ['role' => 'owner']);
$category = Category::factory()->withTranslations()->create(['portal' => Portal::Presseecho, 'is_active' => true]);
$inactive = Category::factory()->withTranslations()->create(['portal' => Portal::Presseecho, 'is_active' => false]);
$foreign = Category::factory()->withTranslations()->create(['portal' => Portal::Businessportal24, 'is_active' => true]);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
$payload = fn (array $overrides): array => [
'company_id' => $company->id,
'category_id' => $category->id,
'language' => 'de',
'title' => 'Vertragspruefung',
'text' => 'Inhalt',
...$overrides,
];
$this->postJson('/api/v1/press-releases', $payload(['language' => 'zz']))->assertUnprocessable()->assertJsonValidationErrors(['language']);
$this->postJson('/api/v1/press-releases', $payload(['category_id' => $inactive->id]))->assertUnprocessable()->assertJsonValidationErrors(['category_id']);
$this->postJson('/api/v1/press-releases', $payload(['category_id' => $foreign->id]))->assertUnprocessable()->assertJsonValidationErrors(['category_id']);
$this->postJson('/api/v1/press-releases', $payload(['language' => 'en']))->assertCreated();
});

View file

@ -392,3 +392,14 @@ test('the admin form keeps the auto origin of untouched rows and marks changed r
$user->syncCompaniesWithRoles([$auto->id], [$auto->id => 'owner']);
expect($user->companies()->withoutGlobalScopes()->whereKey($auto->id)->first()->pivot->granted_via)->toBe('manual');
});
test('a company email stored with capitals still links the lowercase account on login (Audit F22)', function () {
/** @var TestCase $this */
$user = User::factory()->create(['email' => 'gross@example.test', 'is_active' => true]);
$user->assignRole('customer');
$company = Company::factory()->presseecho()->create(['email' => 'Gross@Example.test']);
Volt::test('auth.login')->set('email', 'gross@example.test')->set('password', 'password')->call('login')->assertHasNoErrors();
expect(User::query()->findOrFail($user->id)->accessibleCompanyIds())->toContain($company->id);
});

View file

@ -1,11 +1,15 @@
<?php
use App\Enums\Portal;
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Customer\CustomerCompanyContext;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
@ -376,3 +380,105 @@ test('member customer cannot manage press kit company master data', function ()
->call('startEditCompany')
->assertForbidden();
});
test('a deleted press kit contact disappears from the list and cannot be edited (Audit F04)', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$company = Company::factory()->presseecho()->create(['owner_user_id' => $customer->id]);
$contact = Contact::factory()->for($company)->create([
'first_name' => 'Gelöschte',
'last_name' => 'Kontaktperson',
'portal' => $company->portal->value,
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
->assertSee('Kontaktperson')
->call('deleteContact', $contact->id)
->assertDontSee('Kontaktperson');
expect(fn () => LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
->assertDontSee('Kontaktperson')
->call('editContact', $contact->id))
->toThrow(ModelNotFoundException::class);
});
test('saving or deleting a press kit contact drops the cached newsroom page', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create(['is_active' => true]);
$customer->companies()->attach($company->id, ['role' => 'responsible']);
PressRelease::factory()
->published()
->forPortal(Portal::Presseecho)
->for($company)
->create(['language' => 'de', 'published_at' => now()->subDay()]);
$url = 'https://presseecho.test/de/newsroom/'.$company->slug;
$cacheKey = fn (): string => webPageCacheKey('web.newsroom.presseecho-both.de.'.$company->slug.'.page.1');
$this->actingAs($customer);
// Aufwärmen — die Kontakte liegen mit im gecachten Seitenpaket.
$this->get($url)->assertOk();
expect(Cache::has($cacheKey()))->toBeTrue();
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
->call('startCreateContact')
->set('contactLastName', 'Neu')
->set('contactEmail', 'neu@example.test')
->call('saveContact')
->assertHasNoErrors();
// Der Key trägt die Generation: nach einem Flush existiert der alte
// Eintrag unter dem neuen Key nicht mehr.
expect(Cache::has($cacheKey()))->toBeFalse();
$contact = Contact::query()->where('company_id', $company->id)->firstOrFail();
$this->get($url)->assertOk();
expect(Cache::has($cacheKey()))->toBeTrue();
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
->call('deleteContact', $contact->id);
expect(Cache::has($cacheKey()))->toBeFalse();
});
test('a failed logo write keeps the previous press kit logo and reports a validation error', function () {
/** @var TestCase $this */
$disk = Storage::fake('public');
$disk->put('company-logos/presseecho/7/alt.png', 'alt');
$disk->put('company-logos/presseecho/7/alt-sq.png', 'alt-sq');
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create([
'logo_path' => 'company-logos/presseecho/7/alt.png',
'logo_variants' => ['sq' => 'company-logos/presseecho/7/alt-sq.png'],
]);
$customer->companies()->attach($company->id, ['role' => 'responsible']);
// Proxy auf den echten Fake: nur put() schlägt fehl (voller Datenträger,
// Rechteproblem), alles andere — auch Livewires Temp-Upload — läuft echt.
$failingDisk = Mockery::mock($disk);
$failingDisk->shouldReceive('put')->andReturn(false);
Storage::set('public', $failingDisk);
$this->actingAs($customer);
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
->call('startEditCompany')
->set('companyLogo', UploadedFile::fake()->image('neu.png', 400, 400))
->call('saveCompany')
->assertHasErrors(['companyLogo']);
$company->refresh();
expect($company->logo_path)->toBe('company-logos/presseecho/7/alt.png')
->and($company->logo_variants)->toBe(['sq' => 'company-logos/presseecho/7/alt-sq.png'])
->and($disk->exists('company-logos/presseecho/7/alt.png'))->toBeTrue()
->and($disk->exists('company-logos/presseecho/7/alt-sq.png'))->toBeTrue();
});

View file

@ -0,0 +1,71 @@
<?php
use Illuminate\Support\Facades\File;
/**
* `withoutGlobalScopes()` ohne Argument entfernt neben dem PortalScope auch
* den Soft-Delete-Filter (Audit F04): gelöschte Meldungen, Firmen und
* Kontakte tauchten in API, Vorschau, Medien, Kundenpanel und auf der
* öffentlichen Meldungsseite wieder auf. Öffentliche, Kunden- und API-Pfade
* schreiben deshalb `withoutGlobalScope(PortalScope::class)`.
*
* Die Liste unten sind die bewusst verbliebenen Stellen: Admin-Verwaltung
* (darf Gelöschtes sehen), Importer und Befehle, Slug-Eindeutigkeit (muss
* gelöschte Zeilen einschließen) und die Newsletter-Anmeldung. Wer eine
* neue Stelle braucht, trägt sie hier ein und begründet das im Commit.
*/
test('withoutGlobalScopes() without arguments stays confined to admin, import and slug code (Audit F04)', function () {
$allowed = [
'app/Console/Commands/CheckMailDomains.php',
'app/Console/Commands/ImportNewsletterBounces.php',
'app/Console/Commands/ListCompanyMerges.php',
'app/Console/Commands/MigrateLegacyMedia.php',
'app/Console/Commands/PurgeExpiredPressReleaseDrafts.php',
'app/Console/Commands/RetireAgencyCompanyType.php',
'app/Console/Commands/SeedLegacyApiTestdata.php',
'app/Console/Commands/SyncCompanyLogos.php',
'app/Console/Commands/SyncPressReleaseImages.php',
'app/Http/Controllers/Admin/DashboardController.php',
'app/Http/Controllers/Api/V1/NewsletterSubscriptionController.php',
'app/Http/Controllers/Web/LegacyMediaRedirectController.php',
'app/Livewire/Concerns/HandlesAdminPressReleaseActions.php',
'app/Models/Company.php',
'app/Models/Concerns/HasUniqueSlug.php',
'app/Services/Admin/AdminPerformanceCache.php',
'app/Services/Company/CompanyMergeService.php',
'app/Services/Import/CompanyImporter.php',
'app/Services/Import/ContactImporter.php',
'app/Services/Import/PressReleaseImporter.php',
'app/Services/Mail/SubmitterContactReport.php',
'app/Services/PressRelease/CanonicalAssigner.php',
'app/Services/PressRelease/CorrectionService.php',
'app/Services/PressRelease/ProofPdfRenderer.php',
'app/Services/SearchConsole/IndexStatusReport.php',
'resources/views/livewire/admin/categories/edit.blade.php',
'resources/views/livewire/admin/categories/index.blade.php',
'resources/views/livewire/admin/companies/index.blade.php',
'resources/views/livewire/admin/companies/show.blade.php',
'resources/views/livewire/admin/contacts/create.blade.php',
'resources/views/livewire/admin/contacts/edit.blade.php',
'resources/views/livewire/admin/contacts/index.blade.php',
'resources/views/livewire/admin/press-releases/create.blade.php',
'resources/views/livewire/admin/press-releases/edit.blade.php',
'resources/views/livewire/admin/press-releases/index.blade.php',
'resources/views/livewire/admin/press-releases/show.blade.php',
'resources/views/livewire/admin/reports/submitter-contacts.blade.php',
'resources/views/livewire/admin/users.blade.php',
'resources/views/livewire/admin/users/create.blade.php',
'resources/views/livewire/admin/users/edit.blade.php',
];
$hits = collect([app_path(), resource_path('views'), base_path('routes')])
->flatMap(fn (string $dir) => File::allFiles($dir))
->filter(fn ($file) => str_ends_with($file->getFilename(), '.php'))
->filter(fn ($file) => str_contains($file->getContents(), 'withoutGlobalScopes()'))
->map(fn ($file) => str_replace(base_path().'/', '', $file->getPathname()))
->sort()
->values()
->all();
expect(array_values(array_diff($hits, $allowed)))->toBe([], 'Neue Stelle mit withoutGlobalScopes() ohne Argument – bitte withoutGlobalScope(PortalScope::class) verwenden oder die Ausnahme hier begründen.');
});

View file

@ -31,7 +31,11 @@ function publishedReleaseWithContact(string $contactEmail = 'presse@firma.exampl
function detailUrlFor(PressRelease $pressRelease): string
{
return 'https://presseecho.test/de/press-release/'.$pressRelease->slug;
// Die Zuordnung prüft den Host: Die URL muss zum Portal der PM gehören
// (F12) — die Factory würfelt das Portal.
$base = (string) config('domains.domains.'.$pressRelease->portal->value.'.url');
return $base.'/de/press-release/'.$pressRelease->slug;
}
test('the intake sends a magic link to the stored press contact, never to a form address', function () {

View file

@ -256,3 +256,42 @@ test('attachments cannot be uploaded when PR is published', function () {
expect($pr->attachments()->count())->toBe(0);
});
test('a failed disk write is reported instead of returning file metadata', function () {
/** @var TestCase $this */
['pr' => $pr] = makeDraftWithOwner();
// Proxy auf den echten Fake: nur put() schlägt fehl (voller Datenträger,
// Rechteproblem — die Disks sind mit throw=false konfiguriert, F23).
$failingDisk = Mockery::mock(privateAttachmentDisk());
$failingDisk->shouldReceive('put')->andReturn(false);
Storage::set('local', $failingDisk);
$file = UploadedFile::fake()->create('factsheet.pdf', 200, 'application/pdf');
expect(fn () => app(PressReleaseAttachmentStorage::class)->store($file, $pr->id))
->toThrow(RuntimeException::class, 'Der Anhang konnte nicht gespeichert werden.');
});
test('a failed disk write creates no attachment row', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner();
$this->actingAs($owner);
$failingDisk = Mockery::mock(privateAttachmentDisk());
$failingDisk->shouldReceive('put')->andReturn(false);
Storage::set('local', $failingDisk);
$file = UploadedFile::fake()->create('factsheet.pdf', 200, 'application/pdf');
expect(fn () => LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
->set('newFile', $file)
->set('newTitle', 'Factsheet Q1')
->call('upload'))
->toThrow(RuntimeException::class);
// Kein Datensatz ohne Datei: Sonst stünde im Pressefach ein Anhang, der
// beim Abruf ins Leere läuft.
expect(PressReleaseAttachment::query()->where('press_release_id', $pr->id)->count())->toBe(0);
});

View file

@ -282,3 +282,53 @@ test('submitForReview dispatches the classification job onto the classification
return $job->pressReleaseId === $pressRelease->id;
});
});
test('a classification for content that changed during the analysis is discarded and re-queued (Audit F07)', function () {
Queue::fake();
config()->set('scoring.classification.provider', 'openai');
config()->set('services.openai.api_key', 'test-key');
$pressRelease = PressRelease::factory()->create(['title' => 'Version eins', 'text' => 'Inhalt eins', 'status' => PressReleaseStatus::Review->value]);
$hashAtDispatch = $pressRelease->content_hash;
// Während der KI-Aufruf läuft, speichert ein Admin Version zwei.
Http::fake(function () use ($pressRelease) {
PressRelease::withoutGlobalScopes()->whereKey($pressRelease->id)->first()->update(['text' => 'Inhalt zwei']);
return Http::response(['choices' => [['message' => ['content' => json_encode(['classification' => 'green', 'reasons' => []])]]]], 200);
});
(new ClassifyPressRelease($pressRelease->id, route: true, contentHash: $hashAtDispatch))->handle(
app(ClassificationManager::class),
app(PressReleaseService::class),
);
$fresh = $pressRelease->fresh();
// Das alte Ergebnis darf Version zwei weder bewerten noch freigeben.
expect($fresh->classification)->toBeNull()
->and($fresh->status)->toBe(PressReleaseStatus::Review);
$audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail();
expect($audit->discarded_at)->not->toBeNull()
->and($audit->content_hash)->toBe($hashAtDispatch);
Queue::assertPushed(ClassifyPressRelease::class, fn (ClassifyPressRelease $job) => $job->pressReleaseId === $pressRelease->id
&& $job->route === true
&& $job->contentHash === $fresh->content_hash);
});
test('a classification for unchanged content is stored with its content hash', function () {
fakeOpenAiClassification('green');
$pressRelease = PressRelease::factory()->create(['title' => 'Stabil', 'text' => 'Inhalt']);
(new ClassifyPressRelease($pressRelease->id, route: false, contentHash: $pressRelease->content_hash))->handle(
app(ClassificationManager::class),
app(PressReleaseService::class),
);
$audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail();
expect($audit->content_hash)->toBe($pressRelease->content_hash)
->and($audit->discarded_at)->toBeNull()
->and($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Green);
});

View file

@ -149,3 +149,38 @@ test('rescoreIfScored dispatches only for an already scored press release', func
Queue::assertPushed(ScorePressRelease::class, 1);
});
test('a score for content that changed during the analysis is discarded and re-queued (Audit F07)', function () {
Queue::fake();
config()->set('scoring.content_score.provider', 'openai');
config()->set('services.openai.api_key', 'test-key');
$pressRelease = PressRelease::factory()->create(['title' => 'Version eins', 'text' => 'Inhalt eins']);
$hashAtDispatch = $pressRelease->content_hash;
Http::fake(function () use ($pressRelease) {
PressRelease::withoutGlobalScopes()->whereKey($pressRelease->id)->first()->update(['text' => 'Inhalt zwei']);
return Http::response(['choices' => [['message' => ['content' => json_encode(['score' => 90, 'breakdown' => ['pressestil' => 20]])]]]], 200);
});
(new ScorePressRelease($pressRelease->id, contentHash: $hashAtDispatch))->handle(app(ContentScoreManager::class));
$fresh = $pressRelease->fresh();
expect($fresh->content_score)->toBeNull();
$audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail();
expect($audit->discarded_at)->not->toBeNull();
Queue::assertPushed(ScorePressRelease::class, fn (ScorePressRelease $job) => $job->pressReleaseId === $pressRelease->id
&& $job->contentHash === $fresh->content_hash);
});
test('the score job has a timeout that fits the http budget and retry_after (Audit F17)', function () {
$job = new ScorePressRelease(1);
// Drei Versuche à 30 s plus Verbindungsaufbau passen nicht in die 60 s des
// Workers; der Job trägt sein eigenes Budget, und retry_after liegt darüber.
expect($job->timeout)->toBeGreaterThanOrEqual(120)
->and((int) config('queue.connections.database.retry_after'))->toBeGreaterThan($job->timeout);
});

View file

@ -234,7 +234,7 @@ test('invalid token returns 404', function () {
->assertSee('ungültig');
});
test('preview link for soft deleted press release returns 404', function () {
test('preview link for permanently deleted press release returns 404', function () {
/** @var TestCase $this */
$owner = User::factory()->create();
$pr = PressRelease::factory()->create(['user_id' => $owner->id]);
@ -247,6 +247,22 @@ test('preview link for soft deleted press release returns 404', function () {
->assertStatus(404);
});
test('preview link for soft deleted press release returns 404 (Audit F04)', function () {
/** @var TestCase $this */
$owner = User::factory()->create();
$pr = PressRelease::factory()->create(['user_id' => $owner->id, 'title' => 'Geloeschte Vorschau-Meldung']);
$share = app(MagicLinkGenerator::class)->createPressReleaseShareLink($pr, $owner);
// Normale Löschung (Soft Delete) – der Vorschau-Token darf die Meldung
// nicht mehr laden, auch wenn die Zeile noch existiert.
$pr->delete();
$this->get(route('press-releases.preview', ['token' => $share['plain_token']]))
->assertStatus(404)
->assertDontSee('Geloeschte Vorschau-Meldung');
});
test('share panel offers a direct open button for single portal releases', function () {
/** @var TestCase $this */
[$customer, $pr] = makePreviewCustomerWithPressRelease(Portal::Presseecho);

View file

@ -14,6 +14,7 @@ use App\Models\UserPaymentOption;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
@ -236,3 +237,38 @@ test('monthly reset command zeroes the used counter', function () {
expect(User::where('press_release_quota_used_this_month', '>', 0)->count())->toBe(0);
expect($untouched->fresh()->press_release_quota_used_this_month)->toBe(0);
});
test('quota consumption resets lazily when the calendar month changes, without the cron (Audit F18)', function () {
Carbon::setTestNow('2026-09-18 10:00:00');
$user = quotaTestSubscriber(planQuota: 3, used: 3);
// Verbrauch aus dem Vormonat – der Reset-Cron ist ausgefallen.
$user->forceFill(['press_release_quota_period' => '2026-08'])->save();
expect($user->fresh()->pressReleaseQuotaRemaining())->toBe(3);
$pr = quotaTestPressRelease($user, 'review');
app(PressReleaseService::class)->publish($pr);
$fresh = $user->fresh();
expect($fresh->press_release_quota_used_this_month)->toBe(1)
->and($fresh->press_release_quota_period)->toBe('2026-09')
->and($fresh->pressReleaseQuotaRemaining())->toBe(2);
});
test('the monthly reset is idempotent and leaves this month\'s consumption alone (Audit F18)', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-09-01 00:05:00');
$thisMonth = User::factory()->create(['press_release_quota_used_this_month' => 2, 'press_release_quota_period' => '2026-09']);
$lastMonth = User::factory()->create(['press_release_quota_used_this_month' => 2, 'press_release_quota_period' => '2026-08']);
$unknown = User::factory()->create(['press_release_quota_used_this_month' => 2, 'press_release_quota_period' => null]);
$this->artisan(ResetMonthlyPressReleaseQuota::class)->assertSuccessful();
$this->artisan(ResetMonthlyPressReleaseQuota::class)->assertSuccessful();
expect($thisMonth->fresh()->press_release_quota_used_this_month)->toBe(2)
->and($lastMonth->fresh()->press_release_quota_used_this_month)->toBe(0)
->and($lastMonth->fresh()->press_release_quota_period)->toBe('2026-09')
->and($unknown->fresh()->press_release_quota_used_this_month)->toBe(0);
});

View file

@ -3,6 +3,8 @@
use App\Console\Commands\PublishScheduledPressReleases;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Jobs\ClassifyPressRelease;
use App\Models\KiAudit;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Services\PressRelease\PressReleaseService;
@ -10,6 +12,7 @@ use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
beforeEach(function (): void {
@ -244,3 +247,72 @@ test('Command publisht maximal --limit pro Lauf', function () {
expect($publishedCount)->toBe(2);
});
test('the scheduler reports failure when a due release cannot be published (Audit F20)', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'scheduled_at' => '2026-06-01 11:55:00',
'published_at' => null,
]);
$this->mock(PressReleaseService::class)
->shouldReceive('publish')
->once()
->andThrow(new RuntimeException('Datenbank nicht erreichbar'));
$this->artisan(PublishScheduledPressReleases::class)->assertFailed();
});
test('the scheduler re-checks a due release whose classification predates the last edit instead of publishing it (Audit F07)', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
Queue::fake();
$stale = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'scheduled_at' => '2026-06-01 11:55:00',
'published_at' => null,
]);
KiAudit::factory()->create([
'press_release_id' => $stale->id,
'type' => KiAudit::TYPE_CLASSIFICATION,
'result' => 'green',
'content_hash' => str_repeat('0', 64),
]);
$current = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'scheduled_at' => '2026-06-01 11:55:00',
'published_at' => null,
]);
KiAudit::factory()->create([
'press_release_id' => $current->id,
'type' => KiAudit::TYPE_CLASSIFICATION,
'result' => 'green',
'content_hash' => $current->content_hash,
]);
// Ohne Audit (Altbestand vor der Hash-Bindung) wird wie bisher veröffentlicht.
$legacy = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'scheduled_at' => '2026-06-01 11:55:00',
'published_at' => null,
]);
Artisan::call(PublishScheduledPressReleases::class);
expect($stale->fresh()->status)->toBe(PressReleaseStatus::Review)
->and($current->fresh()->status)->toBe(PressReleaseStatus::Published)
->and($legacy->fresh()->status)->toBe(PressReleaseStatus::Published);
Queue::assertPushed(ClassifyPressRelease::class, fn (ClassifyPressRelease $job) => $job->pressReleaseId === $stale->id
&& $job->route === true
&& $job->contentHash === $stale->fresh()->content_hash);
});

View file

@ -0,0 +1,58 @@
<?php
use App\Models\Company;
use App\Support\WebPageCache;
use Tests\TestCase;
/**
* Öffentliche Seiten-Caches müssen jeden Sichtbarkeitswechsel einer Firma
* mitbekommen — nicht nur den 410-Weg über den CompanyRemovalService (F13).
* Der Flush hängt deshalb am Model, nicht am Admin-Formular.
*/
test('soft-deleting a company flushes the public page caches', function () {
/** @var TestCase $this */
$company = Company::factory()->businessportal24()->create();
$before = WebPageCache::generation();
$company->delete();
expect(WebPageCache::generation())->toBe($before + 1);
});
test('restoring a soft-deleted company flushes the public page caches', function () {
/** @var TestCase $this */
$company = Company::factory()->businessportal24()->create();
$company->delete();
$before = WebPageCache::generation();
$company->restore();
expect(WebPageCache::generation())->toBe($before + 1);
});
test('changing is_active flushes the public page caches', function () {
/** @var TestCase $this */
$company = Company::factory()->businessportal24()->create(['is_active' => true]);
$before = WebPageCache::generation();
$company->update(['is_active' => false]);
expect(WebPageCache::generation())->toBe($before + 1);
$company->update(['is_active' => true]);
expect(WebPageCache::generation())->toBe($before + 2);
});
test('a change without public effect does not flush the page caches', function () {
/** @var TestCase $this */
$company = Company::factory()->businessportal24()->create();
$before = WebPageCache::generation();
// Telefonnummer und ein Save ohne Änderung: kein Grund, die gesamte
// öffentliche Seite in den Neuaufbau zu zwingen.
$company->update(['phone' => '+49 30 555 0']);
$company->touch();
expect(WebPageCache::generation())->toBe($before);
});

View file

@ -165,9 +165,16 @@ test('a fully deleted newsroom still answers 404', function () {
goneTestRelease($company);
$url = 'https://businessportal24.test/de/newsroom/'.$company->slug;
// Erst aufwärmen: Die normale Löschung muss auch einen bereits
// gecachten Newsroom sofort aus der Auslieferung nehmen — als 404,
// nicht als Tombstone (F13).
$this->get($url)->assertOk();
$company->delete();
$this->get($url)->assertNotFound();
$this->get($url)
->assertNotFound()
->assertDontSeeText('Dieser Newsroom wurde entfernt');
});
test('removing a newsroom leaves its press releases alone unless asked to cascade', function () {

View file

@ -179,3 +179,60 @@ test('legacy newsroom html urls redirect to the clean edition path', function ()
$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();
});

View file

@ -245,3 +245,23 @@ test('deleting a press release removes its tracked events', function () {
expect(PressReleaseEvent::query()->count())->toBe(0);
});
test('a second aggregation run is refused while another one holds the lock (Audit F19)', function () {
/** @var TestCase $this */
$release = trackingRelease(['hits' => 0]);
$url = 'https://businessportal24.test/de/press-release/'.$release->slug;
$this->get($url)->assertSuccessful();
$lock = Cache::lock(AggregatePressReleaseViews::LockKey, 60);
expect($lock->get())->toBeTrue();
// Manueller Lauf parallel zum Scheduler: nichts verbuchen, nicht abstürzen.
$this->artisan('press-releases:aggregate-views')->assertSuccessful();
expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(0);
$lock->release();
$this->artisan('press-releases:aggregate-views')->assertSuccessful();
expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(1);
});

View file

@ -481,3 +481,30 @@ test('a single recommendation list spans the full layout width', function () {
->assertSee('grid grid-cols-1 gap-10"', false)
->assertDontSee('grid grid-cols-1 gap-10 md:grid-cols-2', false);
});
test('a deleted company contact is not shown as fallback press contact (Audit F04)', function () {
/** @var TestCase $this */
[$category, $company] = detailCategoryAndCompany(Portal::Businessportal24);
$release = PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->for($category)
->for($company)
->create(['slug' => 'ohne-eigenen-kontakt', 'published_at' => now()->subDay()]);
// Kein PM-Kontakt → die Seite fällt auf den Firmen-Kontakt zurück. Ein
// gelöschter Kontakt darf dabei nicht öffentlich erscheinen.
Contact::factory()->create([
'portal' => Portal::Businessportal24,
'company_id' => $company->id,
'first_name' => 'Ehemalige',
'last_name' => 'Sprecherin',
'email' => 'ehemalig@muster-energie.de',
])->delete();
$this->get('https://businessportal24.test/de/press-release/'.$release->slug)
->assertSuccessful()
->assertDontSeeText('Ehemalige Sprecherin')
->assertDontSee('ehemalig@muster-energie.de');
});

View file

@ -62,7 +62,7 @@ test('a reported url matching a published release is linked to it', function ()
->create(['published_at' => now()->subDay()]);
$this->post('https://businessportal24.test/de/melden/rechtswidrig', [
'reported_url' => 'https://businessportal24.com/de/press-release/'.$release->slug,
'reported_url' => 'https://businessportal24.test/de/press-release/'.$release->slug,
'requester_name' => 'Erika Musterfrau',
'requester_email' => 'erika@example.com',
'message' => 'Diese Mitteilung verletzt meine Markenrechte an mehreren Stellen.',
@ -72,6 +72,33 @@ test('a reported url matching a published release is linked to it', function ()
expect(LegalRequest::query()->sole()->press_release_id)->toBe($release->id);
});
test('a reported url on a foreign host or the other portal is not linked to a release', function () {
/** @var TestCase $this */
Mail::fake();
$release = PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->create(['published_at' => now()->subDay()]);
// Fremde Domain mit passendem Pfad und die URL des anderen Portals: beide
// gehören nicht zu dieser PM — die Meldung bleibt ohne PM-Bezug (F12).
foreach (['https://example.com', 'https://presseecho.test'] as $base) {
$this->post('https://businessportal24.test/de/melden/rechtswidrig', [
'reported_url' => $base.'/de/press-release/'.$release->slug,
'requester_name' => 'Erika Musterfrau',
'requester_email' => 'erika@example.com',
'message' => 'Diese Mitteilung verletzt meine Markenrechte an mehreren Stellen.',
'truth_affirmed' => '1',
])->assertSessionHas('report-status');
}
expect(LegalRequest::query()->count())->toBe(2)
->and(LegalRequest::query()->whereNotNull('press_release_id')->count())->toBe(0)
// Ohne PM-Bezug entscheidet der Host des Formulars über das Portal.
->and(LegalRequest::query()->pluck('portal')->unique()->all())->toBe(['businessportal24']);
});
test('the illegal content report requires the truthfulness affirmation and a valid url', function () {
/** @var TestCase $this */
Mail::fake();

View file

@ -0,0 +1,88 @@
<?php
use App\Enums\Portal;
use App\Models\Company;
use App\Models\PressRelease;
use App\Services\Web\ReportedUrlResolver;
use Tests\TestCase;
/**
* Eine gemeldete URL gehört genau zu einer Pressemitteilung: Der Slug ist nur
* innerhalb von Portal UND Sprache eindeutig (F12). Host → Portal, erstes
* Pfadsegment → Ausgabe, dann erst der Slug.
*/
function resolverRelease(Portal $portal, string $slug, string $language = 'de', array $attributes = []): PressRelease
{
return PressRelease::factory()
->published()
->forPortal($portal)
->for(Company::factory()->state(['portal' => $portal->value]))
->create(array_merge([
'slug' => $slug,
'language' => $language,
'published_at' => now()->subDay(),
], $attributes));
}
test('the same slug on both portals resolves to the release of the reported host', function () {
/** @var TestCase $this */
$presseecho = resolverRelease(Portal::Presseecho, 'gleicher-slug');
$businessportal = resolverRelease(Portal::Businessportal24, 'gleicher-slug');
$resolver = app(ReportedUrlResolver::class);
expect($resolver->resolve('https://presseecho.test/de/press-release/gleicher-slug')?->id)->toBe($presseecho->id)
->and($resolver->resolve('https://businessportal24.test/de/press-release/gleicher-slug')?->id)->toBe($businessportal->id);
});
test('the same slug in both editions resolves to the release of the reported edition', function () {
/** @var TestCase $this */
$german = resolverRelease(Portal::Presseecho, 'zwei-sprachen', 'de');
$english = resolverRelease(Portal::Presseecho, 'zwei-sprachen', 'en');
$resolver = app(ReportedUrlResolver::class);
expect($resolver->resolve('https://presseecho.test/en/press-release/zwei-sprachen')?->id)->toBe($english->id)
->and($resolver->resolve('https://presseecho.test/de/press-release/zwei-sprachen')?->id)->toBe($german->id)
// Ohne Ausgabe im Pfad gilt die Standard-Ausgabe (SetEdition).
->and($resolver->resolve('https://presseecho.test/press-release/zwei-sprachen')?->id)->toBe($german->id);
});
test('a foreign host with a matching path resolves to nothing', function () {
/** @var TestCase $this */
resolverRelease(Portal::Presseecho, 'fremder-host');
$resolver = app(ReportedUrlResolver::class);
expect($resolver->resolve('https://example.com/de/press-release/fremder-host'))->toBeNull()
->and($resolver->resolve('https://presseecho.test.evil.example/de/press-release/fremder-host'))->toBeNull()
// Der Hub liefert keine Meldungen aus, er leitet auf das Portal um.
->and($resolver->resolve('https://pressekonto.test/de/press-release/fremder-host'))->toBeNull()
->and($resolver->resolve('kein-url'))->toBeNull();
});
test('a www host and an url-encoded slug still resolve', function () {
/** @var TestCase $this */
$release = resolverRelease(Portal::Businessportal24, 'kaffee-kuchen');
$resolver = app(ReportedUrlResolver::class);
expect($resolver->resolve('https://www.businessportal24.test/de/press-release/kaffee%2Dkuchen?utm_source=x#top')?->id)->toBe($release->id);
});
test('deleted, scheduled and unpublished releases resolve to nothing', function () {
/** @var TestCase $this */
resolverRelease(Portal::Presseecho, 'geloescht')->delete();
resolverRelease(Portal::Presseecho, 'terminiert', 'de', ['published_at' => now()->addDay()]);
PressRelease::factory()->forPortal(Portal::Presseecho)->create(['slug' => 'entwurf', 'language' => 'de']);
$resolver = app(ReportedUrlResolver::class);
expect($resolver->resolve('https://presseecho.test/de/press-release/geloescht'))->toBeNull()
->and($resolver->resolve('https://presseecho.test/de/press-release/terminiert'))->toBeNull()
->and($resolver->resolve('https://presseecho.test/de/press-release/entwurf'))->toBeNull();
});
test('a portal-wide copy loses against the canonical release with the same slug', function () {
/** @var TestCase $this */
$canonical = resolverRelease(Portal::Presseecho, 'doppelt');
$copy = resolverRelease(Portal::Both, 'doppelt', 'de', ['canonical_press_release_id' => $canonical->id]);
expect($copy->id)->toBeGreaterThan($canonical->id)
->and(app(ReportedUrlResolver::class)->resolve('https://presseecho.test/de/press-release/doppelt')?->id)->toBe($canonical->id);
});