presseportale/tests/Feature/Admin/AdminCategoryManagementTest.php
Kevin Adametz d6e49c11c3 web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)
- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue
  Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt()
  für Sitemap-lastmod, dateModified und article:modified_time
- R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen
- R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt —
  hart wie weich — nur ein gültig signierter Rückläufer, unsignierte
  standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy)
- R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück
  (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und
  Unmerge, assignAll setzt veraltete Verweise zurück
- R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und
  verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach
  395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert,
  Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst
- N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap
- OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID

Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und
wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt
committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00

198 lines
7.3 KiB
PHP

<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\PressRelease;
use App\Models\User;
use App\Support\WebPageCache;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Cache;
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();
});
test('creating an active category clears the cached public pages at once', function () {
/** @var TestCase $this */
$generation = WebPageCache::generation();
Cache::put('web.llms.presseecho.test', 'alt', 3600);
LivewireVolt::test('admin.categories.create')
->set('portal', Portal::Presseecho->value)
->set('nameDe', 'Energie')
->set('nameEn', 'Energy')
->call('save')
->assertHasNoErrors();
// Umsetzungsaudit 29.09.2026, 5.2-Rest: Navigation und llms.txt zeigen
// die neue Branche sofort, nicht erst nach bis zu einer Stunde.
expect(WebPageCache::generation())->not->toBe($generation)
->and(Cache::has('web.llms.presseecho.test'))->toBeFalse();
});