presseportale/tests/Feature/Admin/AdminCategoryManagementTest.php
Kevin Adametz 2a2215b3c3 audit: Nacharbeit Umsetzungsaudit 25.09.2026 — Bildfreigabe, Beweisarchiv, KI-Atomarität, Berichte (Block 8, N01–N05, N07, N08, N10–N21, 5.1, 5.2)
Bilder (N01, N05, N07, N08): gemeinsamer Freigabezustand
PressReleaseImage::isPubliclyDeliverable() für Seiten, OG und Medienroute
(Bestand bleibt sichtbar, v2 nur mit Bestätigung, Admin-Upload bis zum
Kundenklick nicht öffentlich); eigene Ability confirmImageRights nur für den
Kontoinhaber, ohne Super-Admin-Kurzschluss, Identität und ausstehende
Bestätigung auch im Service; Status-Regel unter der PM-Sperre; Cache-Flush
nach dem Commit außerhalb der Dateibereinigung; Snapshot nach Bestätigung an
veröffentlichter PM; Bestätigungstexte an die gezeigte Kennung gebunden.

Beweisarchiv (N02, N10, N11, N12, N13, 5.1): Tombstone unter Zeilensperre mit
erneuter Hold-/Fristprüfung; Dateioperationen ausgewertet, Commands mit
FAILURE; record() über .tmp + move; Marker archive_pending_at mit
archive:retry-pending und archive:sweep-orphans; Anonymisierung entfernt Bilder
über den Remover mit Antragsbezug; Draft-Purge räumt Medien nie
veröffentlichter abgelehnter Einreichungen; App-Kopien fallen mit dem
Archiv-Tombstone; label_corrected nur für veröffentlichte Bilder;
archive_entries.legal_request_id; FileDeletion-Helfer; Systemstatus-Checks.

KI und Hintergrund (N03, N04, N14, N20, N21): Ergebnis-UPDATE und Audit in
einer Transaktion; erwarteter Hash bis in reject()/holdForManualReview();
Ablaufmail per beanspruchtem Marker in einer Transaktion; strikte
Schemaprüfung der Vision-Antwort; Fortschrittsmarke ungecacht.

Berichte und Canonical (N15–N19, 5.2): lückenlose Alterskohorten;
Feed-Identität mit Portal und Sprache; Sitemap-Rolle in der Kundenansicht;
Indexverteilung auf einer Grundgesamtheit; Kopien nach Wegfall der
Erstquelle neu bewertet; Kategorie-Admin leert Seiten-Caches; Dry-Run
zählt alle Tabellen.

Gesamtsuite: 2.122 Tests grün, einer übersprungen; Larastan und Pint sauber.
Zwei Migrationen (nullable Spalten, INSTANT). N09 offen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 14:26:49 +00:00

178 lines
6.5 KiB
PHP

<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\PressRelease;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
});
test('admin can render category create page', function () {
/** @var TestCase $this */
$this->get(route('admin.categories.create'))
->assertSuccessful()
->assertSee('Kategorie anlegen');
});
test('admin can create a new category with both translations', function () {
LivewireVolt::test('admin.categories.create')
->set('portal', Portal::Both->value)
->set('nameDe', 'Künstliche Intelligenz')
->set('nameEn', 'Artificial Intelligence')
->set('descriptionDe', 'Themen rund um KI.')
->set('descriptionEn', 'Topics around AI.')
->call('save')
->assertHasNoErrors()
->assertRedirect(route('admin.categories.index'));
$category = Category::query()->latest('id')->firstOrFail();
expect($category->portal)->toBe(Portal::Both);
expect($category->is_active)->toBeTrue();
expect($category->translations()->count())->toBe(2);
$de = $category->translations->firstWhere('locale', 'de');
$en = $category->translations->firstWhere('locale', 'en');
expect($de->name)->toBe('Künstliche Intelligenz');
expect($de->slug)->toBe('kunstliche-intelligenz');
expect($en->slug)->toBe('artificial-intelligence');
});
test('category create requires both names', function () {
LivewireVolt::test('admin.categories.create')
->set('nameDe', '')
->set('nameEn', '')
->call('save')
->assertHasErrors(['nameDe', 'nameEn']);
});
test('category create generates unique slug per locale on collision', function () {
$existing = Category::factory()->create(['portal' => Portal::Both->value]);
$existing->translations()->createMany([
['locale' => 'de', 'name' => 'Wirtschaft', 'slug' => 'wirtschaft'],
['locale' => 'en', 'name' => 'Business', 'slug' => 'business'],
]);
LivewireVolt::test('admin.categories.create')
->set('nameDe', 'Wirtschaft')
->set('nameEn', 'Business')
->call('save')
->assertHasNoErrors();
$newCategory = Category::query()->latest('id')->firstOrFail();
$de = $newCategory->translations->firstWhere('locale', 'de');
$en = $newCategory->translations->firstWhere('locale', 'en');
expect($de->slug)->toBe('wirtschaft-2');
expect($en->slug)->toBe('business-2');
});
test('admin can edit an existing category and rename slugs in place', function () {
$category = Category::factory()->create(['portal' => Portal::Both->value]);
$category->translations()->createMany([
['locale' => 'de', 'name' => 'Alter Name', 'slug' => 'alter-name'],
['locale' => 'en', 'name' => 'Old Name', 'slug' => 'old-name'],
]);
LivewireVolt::test('admin.categories.edit', ['id' => $category->id])
->set('nameDe', 'Neuer Name')
->set('slugDe', 'neuer-name')
->set('nameEn', 'New Name')
->set('slugEn', 'new-name')
->call('save')
->assertHasNoErrors();
$category->refresh()->load('translations');
$de = $category->translations->firstWhere('locale', 'de');
$en = $category->translations->firstWhere('locale', 'en');
expect($de->slug)->toBe('neuer-name');
expect($en->slug)->toBe('new-name');
});
test('category edit refuses parent assignment that would create a hierarchy loop', function () {
$parent = Category::factory()->create();
$parent->translations()->createMany([
['locale' => 'de', 'name' => 'Parent', 'slug' => 'parent'],
['locale' => 'en', 'name' => 'Parent', 'slug' => 'parent-en'],
]);
$child = Category::factory()->create(['parent_id' => $parent->id]);
$child->translations()->createMany([
['locale' => 'de', 'name' => 'Child', 'slug' => 'child'],
['locale' => 'en', 'name' => 'Child', 'slug' => 'child-en'],
]);
LivewireVolt::test('admin.categories.edit', ['id' => $parent->id])
->set('nameDe', 'Parent')
->set('slugDe', 'parent')
->set('nameEn', 'Parent')
->set('slugEn', 'parent-en')
->set('parentId', $child->id)
->call('save')
->assertHasErrors(['parentId']);
});
test('category cannot be deleted while it has press releases', function () {
$category = Category::factory()->create();
$category->translations()->createMany([
['locale' => 'de', 'name' => 'Used', 'slug' => 'used'],
['locale' => 'en', 'name' => 'Used', 'slug' => 'used-en'],
]);
PressRelease::factory()->create([
'category_id' => $category->id,
]);
LivewireVolt::test('admin.categories.edit', ['id' => $category->id])
->call('deleteCategory');
expect(Category::query()->find($category->id))->not->toBeNull();
});
test('category can be deleted when empty', function () {
$category = Category::factory()->create();
$category->translations()->createMany([
['locale' => 'de', 'name' => 'Empty', 'slug' => 'empty'],
['locale' => 'en', 'name' => 'Empty', 'slug' => 'empty-en'],
]);
LivewireVolt::test('admin.categories.edit', ['id' => $category->id])
->call('deleteCategory')
->assertRedirect(route('admin.categories.index'));
expect(Category::query()->find($category->id))->toBeNull();
});
test('deactivating a category clears the cached public pages and its feed at once', function () {
/** @var TestCase $this */
$category = Category::factory()->create(['portal' => Portal::Presseecho->value, 'is_active' => true]);
$category->translations()->createMany([
['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel'],
['locale' => 'en', 'name' => 'Trade', 'slug' => 'trade'],
]);
PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)
->create(['language' => 'de', 'published_at' => now()->subHour()]);
// Feed warm im Seiten-Cache.
$this->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful();
LivewireVolt::test('admin.categories.edit', ['id' => $category->id])
->set('isActive', false)
->call('save')
->assertHasNoErrors();
// Nicht erst nach Ablauf der Stale-Grenze: sofort weg.
$this->get('https://presseecho.test/de/category/handel/feed')->assertNotFound();
});