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>
178 lines
6.5 KiB
PHP
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();
|
|
});
|