12-05-2026 Frontend dev
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run

This commit is contained in:
Kevin Adametz 2026-05-12 18:32:33 +02:00
parent 405df0a122
commit 5b8bdf4182
779 changed files with 480564 additions and 6241 deletions

View file

@ -0,0 +1,156 @@
<?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();
});

View file

@ -0,0 +1,162 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\FooterCode;
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 footer-codes index', function () {
/** @var TestCase $this */
$this->get(route('admin.footer-codes.index'))
->assertSuccessful()
->assertSee('Footer-Codes');
});
test('admin can render footer-code create page', function () {
/** @var TestCase $this */
$this->get(route('admin.footer-codes.create'))
->assertSuccessful()
->assertSee('Footer-Code anlegen');
});
test('admin can create a category-bound footer code', function () {
$category = Category::factory()->create();
$category->translations()->createMany([
['locale' => 'de', 'name' => 'Wirtschaft', 'slug' => 'wirtschaft'],
['locale' => 'en', 'name' => 'Business', 'slug' => 'business'],
]);
LivewireVolt::test('admin.footer-codes.create')
->set('title', 'Wirtschafts-Disclaimer')
->set('content', '<p>Powered by Presseportale</p>')
->set('portal', Portal::Both->value)
->set('language', 'de')
->set('priority', 10)
->set('isGlobal', false)
->set('categoryIds', [$category->id])
->call('save')
->assertHasNoErrors()
->assertRedirect(route('admin.footer-codes.index'));
$code = FooterCode::query()->latest('id')->firstOrFail();
expect($code->title)->toBe('Wirtschafts-Disclaimer');
expect($code->is_global)->toBeFalse();
expect($code->is_active)->toBeTrue();
expect($code->categories->pluck('id')->all())->toBe([$category->id]);
});
test('global footer code ignores category assignment', function () {
$category = Category::factory()->create();
$category->translations()->createMany([
['locale' => 'de', 'name' => 'Tech', 'slug' => 'tech'],
['locale' => 'en', 'name' => 'Tech', 'slug' => 'tech-en'],
]);
LivewireVolt::test('admin.footer-codes.create')
->set('title', 'Globaler Disclaimer')
->set('content', '<p>Hinweis</p>')
->set('isGlobal', true)
->set('categoryIds', [$category->id])
->call('save')
->assertHasNoErrors();
$code = FooterCode::query()->latest('id')->firstOrFail();
expect($code->is_global)->toBeTrue();
expect($code->categories()->count())->toBe(0);
});
test('footer code create requires title and content', function () {
LivewireVolt::test('admin.footer-codes.create')
->set('title', '')
->set('content', '')
->call('save')
->assertHasErrors(['title', 'content']);
});
test('admin can edit and reassign categories of a footer code', function () {
$code = FooterCode::factory()->create([
'title' => 'Alt',
'is_global' => false,
]);
$oldCategory = Category::factory()->create();
$oldCategory->translations()->createMany([
['locale' => 'de', 'name' => 'Alt', 'slug' => 'alt-cat'],
['locale' => 'en', 'name' => 'Old', 'slug' => 'old-cat'],
]);
$code->categories()->sync([$oldCategory->id]);
$newCategory = Category::factory()->create();
$newCategory->translations()->createMany([
['locale' => 'de', 'name' => 'Neu', 'slug' => 'neu-cat'],
['locale' => 'en', 'name' => 'New', 'slug' => 'new-cat'],
]);
LivewireVolt::test('admin.footer-codes.edit', ['id' => $code->id])
->set('title', 'Neu')
->set('content', $code->content)
->set('categoryIds', [$newCategory->id])
->call('save')
->assertHasNoErrors();
$code->refresh();
expect($code->title)->toBe('Neu');
expect($code->categories->pluck('id')->all())->toBe([$newCategory->id]);
});
test('toggling global removes category links on save', function () {
$code = FooterCode::factory()->create(['is_global' => false]);
$category = Category::factory()->create();
$category->translations()->createMany([
['locale' => 'de', 'name' => 'X', 'slug' => 'x-cat'],
['locale' => 'en', 'name' => 'X', 'slug' => 'x-cat-en'],
]);
$code->categories()->sync([$category->id]);
LivewireVolt::test('admin.footer-codes.edit', ['id' => $code->id])
->set('content', $code->content)
->set('isGlobal', true)
->call('save')
->assertHasNoErrors();
$code->refresh();
expect($code->is_global)->toBeTrue();
expect($code->categories()->count())->toBe(0);
});
test('admin can soft-delete a footer code', function () {
$code = FooterCode::factory()->create();
LivewireVolt::test('admin.footer-codes.edit', ['id' => $code->id])
->call('delete')
->assertRedirect(route('admin.footer-codes.index'));
expect(FooterCode::query()->find($code->id))->toBeNull();
expect(FooterCode::withTrashed()->find($code->id))->not->toBeNull();
});
test('admin can toggle footer code activation from index', function () {
$code = FooterCode::factory()->create(['is_active' => true]);
LivewireVolt::test('admin.footer-codes.index')
->call('toggleActive', $code->id);
expect($code->fresh()->is_active)->toBeFalse();
});

View file

@ -0,0 +1,111 @@
<?php
use App\Models\LegacyInvoice;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
beforeEach(function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
});
test('admin sees legacy invoice archive overview with filters and mapping hints', function () {
$customer = User::factory()->create([
'is_active' => true,
'name' => 'Archiv Kunde',
'email' => 'archiv@example.com',
]);
LegacyInvoice::query()->create([
'legacy_portal' => 'presseecho',
'legacy_id' => 2001,
'user_id' => $customer->id,
'legacy_user_id' => 501,
'number' => 'PE-2001',
'amount_cents' => 4900,
'total_cents' => 4900,
'status' => 'paid',
'invoice_date' => now()->subMonth(),
'paid_at' => now()->subMonth()->addDays(2),
'imported_at' => now(),
]);
LegacyInvoice::query()->create([
'legacy_portal' => 'businessportal24',
'legacy_id' => 2002,
'user_id' => null,
'legacy_user_id' => 999,
'number' => 'BP-2002',
'amount_cents' => 11900,
'total_cents' => 11900,
'status' => 'open',
'invoice_date' => now()->subWeeks(2),
'imported_at' => now(),
]);
LivewireVolt::test('admin.invoices.index')
->assertSee('Legacy-Rechnungsarchiv')
->assertSee('PE-2001')
->assertSee('BP-2002')
->assertSee('Ohne Zuordnung')
->assertSee('Archiv Kunde')
->set('mappingFilter', 'unmapped')
->assertSee('BP-2002')
->assertDontSee('PE-2001')
->set('mappingFilter', 'all')
->set('portalFilter', 'presseecho')
->assertSee('PE-2001')
->assertDontSee('BP-2002')
->call('resetFilters')
->assertSee('PE-2001')
->assertSee('BP-2002');
});
test('admin opens generated legacy invoice pdf inline', function () {
/** @var TestCase $this */
$invoice = LegacyInvoice::query()->create([
'legacy_portal' => 'presseecho',
'legacy_id' => 2003,
'user_id' => null,
'legacy_user_id' => 503,
'number' => 'PE-2003',
'amount_cents' => 9900,
'total_cents' => 9900,
'status' => 'paid',
'invoice_date' => now()->subMonth(),
'paid_at' => now()->subMonth()->addDays(2),
'payment_method' => 'SPK_Berlin',
'raw_snapshot' => [
'number' => 'PE-2003',
'service_period_begin_date' => now()->subYear()->toDateString(),
'service_period_end_date' => now()->toDateString(),
],
'pdf_payload' => [
'billing_address' => [
'name' => 'Admin Archiv GmbH',
'address' => 'Archivstrasse 1',
'postal_code' => '10115',
'city' => 'Berlin',
'country_name' => 'Deutschland',
],
],
'imported_at' => now(),
]);
LivewireVolt::test('admin.invoices.index')
->assertSee('Öffnen');
$this->get(route('admin.legacy-invoices.pdf', $invoice))
->assertSuccessful()
->assertHeader('content-type', 'application/pdf')
->assertHeader('content-disposition', 'inline; filename="Presseecho-RNr-PE-2003.pdf"');
expect($invoice->refresh()->pdf_generated_at)->not->toBeNull();
});

View file

@ -0,0 +1,29 @@
<?php
use App\Models\Company;
use App\Services\Admin\AdminPerformanceCache;
use Illuminate\Support\Facades\Cache;
use Spatie\Permission\Models\Role;
test('admin performance cache is flushed when observed models change', function () {
Cache::put(AdminPerformanceCache::DashboardStats, ['stale' => true], 600);
Cache::put(AdminPerformanceCache::PressReleaseStats, ['stale' => true], 600);
Cache::put(AdminPerformanceCache::RoleOptions, ['stale' => true], 600);
Company::factory()->create();
expect(Cache::has(AdminPerformanceCache::DashboardStats))->toBeFalse()
->and(Cache::has(AdminPerformanceCache::PressReleaseStats))->toBeFalse()
->and(Cache::has(AdminPerformanceCache::RoleOptions))->toBeFalse();
});
test('admin role option cache is flushed when roles change', function () {
Cache::put(AdminPerformanceCache::RoleOptions, ['stale' => true], 600);
Role::query()->create([
'name' => 'temporary-admin-role',
'guard_name' => 'web',
]);
expect(Cache::has(AdminPerformanceCache::RoleOptions))->toBeFalse();
});

View file

@ -0,0 +1,78 @@
<?php
use App\Models\AdminPreset;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
test('admin presets are linked in navigation', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin)
->get(route('admin.presets.index'))
->assertSuccessful()
->assertSee('Voreinstellungen');
});
test('admin can create a preset', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
LivewireVolt::test('admin.presets.create')
->set('key', 'press_releases.test_text')
->set('area', 'press_releases')
->set('type', 'text')
->set('label', 'Test Text')
->set('value', 'Ein Preset Text')
->set('payload', '{"source":"test"}')
->call('save')
->assertHasNoErrors()
->assertRedirect(route('admin.presets.index'));
$this->assertDatabaseHas('admin_presets', [
'key' => 'press_releases.test_text',
'area' => 'press_releases',
'type' => 'text',
'label' => 'Test Text',
'value' => 'Ein Preset Text',
]);
});
test('admin can edit a preset', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
$preset = AdminPreset::factory()->create([
'key' => 'press_releases.edit_text',
'area' => 'press_releases',
'label' => 'Alter Text',
'value' => 'Alt',
]);
LivewireVolt::test('admin.presets.edit', ['id' => $preset->id])
->set('label', 'Neuer Text')
->set('value', 'Neu')
->set('isActive', false)
->call('save')
->assertHasNoErrors()
->assertRedirect(route('admin.presets.index'));
$preset->refresh();
expect($preset->label)->toBe('Neuer Text')
->and($preset->value)->toBe('Neu')
->and($preset->is_active)->toBeFalse();
});

View file

@ -0,0 +1,150 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\AdminPreset;
use App\Models\PressRelease;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
test('published press releases are tombstoned instead of soft deleted', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
$replacementText = 'Diese Meldung wurde redaktionell entfernt.';
AdminPreset::factory()->create([
'key' => AdminPreset::PRESS_RELEASE_DELETED_PUBLISHED_TEXT,
'area' => 'press_releases',
'type' => 'text',
'label' => 'Ersatztext',
'value' => $replacementText,
]);
$pressRelease = PressRelease::factory()
->published()
->create(['text' => 'Alter veröffentlichter Inhalt.']);
LivewireVolt::test('admin.press-releases.edit', ['id' => $pressRelease->id])
->call('deletePressRelease')
->assertRedirect(route('admin.press-releases.index'));
$pressRelease->refresh();
expect($pressRelease->deleted_at)->toBeNull()
->and($pressRelease->status)->toBe(PressReleaseStatus::Archived)
->and($pressRelease->text)->toBe($replacementText)
->and($pressRelease->no_export)->toBeTrue();
});
test('unpublished press releases are soft deleted by admin delete action', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
$pressRelease = PressRelease::factory()->create([
'status' => PressReleaseStatus::Draft->value,
]);
LivewireVolt::test('admin.press-releases.edit', ['id' => $pressRelease->id])
->call('deletePressRelease')
->assertRedirect(route('admin.press-releases.index'));
$this->assertSoftDeleted($pressRelease);
});
test('edit page renders status selector and confirmation modal', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
$pressRelease = PressRelease::factory()
->inReview()
->create();
LivewireVolt::test('admin.press-releases.edit', ['id' => $pressRelease->id])
->assertSee('Neuer Status')
->assertSee('Status wechseln')
->assertSee('Status wirklich wechseln?')
->assertSee('Pressemitteilung löschen?');
});
test('admin can change archived press release back to another status', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
$pressRelease = PressRelease::factory()->create([
'status' => PressReleaseStatus::Archived->value,
]);
LivewireVolt::test('admin.press-releases.edit', ['id' => $pressRelease->id])
->set('targetStatus', PressReleaseStatus::Draft->value)
->call('changeStatus')
->assertHasNoErrors()
->assertSee('Status wurde auf');
$pressRelease->refresh();
expect($pressRelease->status)->toBe(PressReleaseStatus::Draft);
});
test('press release index renders confirmation modals for status actions', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
PressRelease::factory()
->inReview()
->create(['title' => 'PM in Prüfung']);
PressRelease::factory()
->published()
->create(['title' => 'Veröffentlichte PM']);
LivewireVolt::test('admin.press-releases.index')
->assertSee('Pressemitteilung veröffentlichen?')
->assertSee('Pressemitteilung ablehnen?')
->assertSee('Pressemitteilung archivieren?');
});
test('show page renders confirmation modals for status actions', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
$reviewPressRelease = PressRelease::factory()
->inReview()
->create();
LivewireVolt::test('admin.press-releases.show', ['id' => $reviewPressRelease->id])
->assertSee('Pressemitteilung veröffentlichen?')
->assertSee('Pressemitteilung ablehnen?');
$publishedPressRelease = PressRelease::factory()
->published()
->create();
LivewireVolt::test('admin.press-releases.show', ['id' => $publishedPressRelease->id])
->assertSee('Pressemitteilung archivieren?');
});

View file

@ -0,0 +1,81 @@
<?php
use App\Http\Middleware\EnsureUserIsAdmin;
use App\Http\Middleware\LogSlowAdminRequests;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Log;
use Livewire\Livewire;
use Mockery as MockeryFacade;
use Psr\Log\LoggerInterface;
use Tests\TestCase;
test('slow admin requests are logged with request and query metrics', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
config([
'admin_performance.slow_requests.enabled' => true,
'admin_performance.slow_requests.duration_threshold_ms' => 0,
'admin_performance.slow_requests.database_threshold_ms' => 0,
'admin_performance.slow_requests.query_count_threshold' => 0,
'admin_performance.slow_requests.slow_query_threshold_ms' => 0,
'admin_performance.slow_requests.channel' => 'admin-slow-test',
]);
$logger = MockeryFacade::mock(LoggerInterface::class);
$logger->shouldReceive('warning')
->once()
->with('Slow admin request detected.', MockeryFacade::on(function (array $context): bool {
return $context['method'] === 'GET'
&& $context['path'] === '/admin/roles'
&& $context['route_name'] === 'admin.roles.index'
&& $context['status_code'] === 200
&& is_int($context['user_id'])
&& is_int($context['duration_ms'])
&& is_float($context['database_time_ms'])
&& is_int($context['query_count'])
&& is_array($context['slow_queries']);
}));
Log::shouldReceive('channel')
->once()
->with('admin-slow-test')
->andReturn($logger);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin)
->get(route('admin.roles.index'))
->assertSuccessful();
});
test('slow admin requests use the dedicated admin log channel by default', function () {
expect(config('admin_performance.slow_requests.channel'))->toBe('admin_slow')
->and(config('logging.channels.admin_slow.driver'))->toBe('daily')
->and(config('logging.channels.admin_slow.path'))->toBe(storage_path('logs/admin-slow.log'))
->and(config('logging.channels.admin_slow.level'))->toBe('warning');
});
test('slow admin request logging can be disabled', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
config(['admin_performance.slow_requests.enabled' => false]);
Log::shouldReceive('channel')->never();
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin)
->get(route('admin.roles.index'))
->assertSuccessful();
});
test('admin middleware is persisted for livewire follow-up requests', function () {
expect(Livewire::getPersistentMiddleware())
->toContain(EnsureUserIsAdmin::class)
->toContain(LogSlowAdminRequests::class);
});

View file

@ -0,0 +1,111 @@
<?php
use App\Models\User;
use App\Services\Admin\AdminSlowRequestReporter;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\File;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
test('slow admin reporter parses and aggregates slow request logs', function () {
$logPath = createAdminSlowRequestLog();
$report = app(AdminSlowRequestReporter::class)->report(
filters: ['route' => 'admin.users'],
top: 5,
limit: 5,
paths: [$logPath],
);
expect($report['summary']['total_requests'])->toBe(2)
->and($report['summary']['unique_routes'])->toBe(1)
->and($report['summary']['max_duration_ms'])->toBe(1400)
->and($report['top_routes'][0]['value'])->toBe('admin.users.index')
->and($report['top_routes'][0]['requests'])->toBe(2)
->and($report['slowest_requests'][0]['duration_ms'])->toBe(1400)
->and($report['slow_queries'][0]['occurrences'])->toBe(2)
->and($report['explain_plans'][0]['explainable'])->toBeTrue()
->and($report['explain_plans'][0]['plan'])->not->toBeEmpty();
});
test('slow admin request command renders report sections', function () {
/** @var TestCase $this */
$logPath = createAdminSlowRequestLog();
$this->artisan('admin:slow-requests', [
'--file' => [$logPath],
'--top' => 5,
'--limit' => 5,
])
->assertSuccessful()
->expectsOutput('Slow-Admin-Request-Report')
->expectsOutput('Requests: 3')
->expectsOutputToContain('Top Routen')
->expectsOutputToContain('Langsamste Requests')
->expectsOutputToContain('EXPLAIN Top Slow Queries');
});
test('admin slow request report page renders filters and report data', function () {
/** @var TestCase $this */
$logPath = createAdminSlowRequestLog();
config(['logging.channels.admin_slow.path' => $logPath]);
config(['admin_performance.slow_requests.enabled' => false]);
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
LivewireVolt::test('admin.reports.slow-requests')
->assertSee('Performance Reports')
->assertSee('Top Routen')
->assertSee('EXPLAIN Top Slow Queries')
->assertSee('admin.users.index')
->set('routeFilter', 'admin.contacts')
->assertSee('admin.contacts.index')
->assertDontSee('admin.users.index');
});
function createAdminSlowRequestLog(): string
{
$path = storage_path('logs/testing-admin-slow.log');
File::ensureDirectoryExists(dirname($path));
File::put($path, collect([
adminSlowRequestLogLine('2026-04-29 10:00:00', 'admin.users.index', '/admin/users', 920, 180.5, 42),
adminSlowRequestLogLine('2026-04-29 10:05:00', 'admin.users.index', '/admin/users?page=2', 1400, 220.75, 58),
adminSlowRequestLogLine('2026-04-29 10:10:00', 'admin.contacts.index', '/admin/contacts', 760, 90.25, 25, 302),
])->implode(PHP_EOL).PHP_EOL);
return $path;
}
function adminSlowRequestLogLine(
string $timestamp,
string $route,
string $path,
int $durationMs,
float $databaseTimeMs,
int $queryCount,
int $statusCode = 200,
): string {
$context = [
'method' => 'GET',
'path' => $path,
'route_name' => $route,
'status_code' => $statusCode,
'user_id' => 1,
'duration_ms' => $durationMs,
'database_time_ms' => $databaseTimeMs,
'query_count' => $queryCount,
'slow_queries' => [
[
'sql' => 'select * from "users" where "email" = ?',
'time_ms' => 75.5,
'connection' => 'sqlite',
],
],
];
return '['.$timestamp.'] local.WARNING: Slow admin request detected. '.json_encode($context, JSON_UNESCAPED_SLASHES);
}

View file

@ -0,0 +1,106 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
test('category index loads release counts without hydrating press release models', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
$company = Company::factory()->create();
$category = Category::factory()->withTranslations()->create();
PressRelease::factory()
->count(2)
->create([
'user_id' => $admin->id,
'company_id' => $company->id,
'category_id' => $category->id,
'portal' => Portal::Presseecho->value,
]);
PressRelease::factory()
->create([
'user_id' => $admin->id,
'company_id' => $company->id,
'category_id' => $category->id,
'portal' => Portal::Businessportal24->value,
]);
LivewireVolt::test('admin.categories.index')
->assertSee(route('admin.press-releases.index', ['category' => $category->id]), false)
->assertViewHas('stats', fn (array $stats): bool => $stats['with_releases'] === 1
&& $stats['total_releases'] === 3
&& $stats['presseecho_releases'] === 2
&& $stats['businessportal24_releases'] === 1)
->assertViewHas('categories', function ($categories): bool {
$firstCategory = $categories->first();
return $firstCategory instanceof Category
&& $firstCategory->press_releases_count === 3
&& $firstCategory->presseecho_press_releases_count === 2
&& $firstCategory->businessportal24_press_releases_count === 1
&& $firstCategory->relationLoaded('translations')
&& ! $firstCategory->relationLoaded('pressReleases');
});
});
test('press release index can be filtered by category and shows category column', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
$longCompanyName = 'Eine sehr lange Firma mit einem Namen der in der Tabelle begrenzt werden muss GmbH';
$company = Company::factory()->create([
'name' => $longCompanyName,
]);
$selectedCategory = Category::factory()->create();
$otherCategory = Category::factory()->create();
$selectedCategory->translations()->createMany([
['locale' => 'de', 'name' => 'Wirtschaft', 'slug' => 'wirtschaft'],
['locale' => 'en', 'name' => 'Business', 'slug' => 'business'],
]);
$otherCategory->translations()->create([
'locale' => 'de',
'name' => 'Technologie',
'slug' => 'technologie',
]);
$longTitle = 'Gefilterte Pressemitteilung mit einem sehr langen Titel der in der Tabelle ebenfalls begrenzt werden muss';
PressRelease::factory()->create([
'user_id' => $admin->id,
'company_id' => $company->id,
'category_id' => $selectedCategory->id,
'title' => $longTitle,
]);
PressRelease::factory()->create([
'user_id' => $admin->id,
'company_id' => $company->id,
'category_id' => $otherCategory->id,
'title' => 'Andere Pressemitteilung',
]);
LivewireVolt::test('admin.press-releases.index')
->set('categoryFilter', (string) $selectedCategory->id)
->assertSee('Kategorie')
->assertSee('Wirtschaft')
->assertSee($longTitle)
->assertSee($longCompanyName)
->assertDontSee('Andere Pressemitteilung');
});

View file

@ -0,0 +1,14 @@
<?php
use App\Models\User;
use Tests\TestCase;
test('admin portal page renders with the portal vite manifest', function () {
/** @var TestCase $this */
$admin = User::factory()->superAdmin()->create();
$this->actingAs($admin)
->get('https://presseportale.test/admin/companies')
->assertSuccessful()
->assertSee('Firmen');
});

View file

@ -0,0 +1,52 @@
<?php
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
test('admin can create a role with permissions', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
LivewireVolt::test('admin.roles.create')
->set('name', 'support')
->set('permissions', ['users:manage', 'roles:manage'])
->call('save')
->assertHasNoErrors()
->assertRedirect(route('admin.roles.index'));
$role = Role::findByName('support');
expect($role->hasPermissionTo('users:manage'))->toBeTrue()
->and($role->hasPermissionTo('roles:manage'))->toBeTrue();
});
test('admin can edit a role with cached permission options', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
$role = Role::query()->create([
'name' => 'reviewer',
'guard_name' => 'web',
]);
LivewireVolt::test('admin.roles.edit', ['id' => $role->id])
->assertSee('roles:manage')
->set('name', 'review-lead')
->set('permissions', ['press-releases:read'])
->call('save')
->assertHasNoErrors()
->assertRedirect(route('admin.roles.index'));
expect(Role::findByName('review-lead')->hasPermissionTo('press-releases:read'))->toBeTrue();
});

View file

@ -0,0 +1,132 @@
<?php
use App\Actions\Admin\UserImpersonation;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
test('admin can impersonate a customer from user management', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create();
$admin->assignRole('admin');
$customer = User::factory()->create([
'name' => 'Test Kunde',
'email' => 'kunde@example.com',
]);
$customer->assignRole('customer');
$this->actingAs($admin);
LivewireVolt::test('admin.users')
->assertSee('Login als User')
->call('loginAsUser', $customer->id)
->assertRedirect(route('me.dashboard'));
$this->assertAuthenticatedAs($customer);
expect(session(UserImpersonation::SessionKey))->toBe($admin->id);
$this->get(route('dashboard'))->assertRedirect(route('me.dashboard'));
$this->get(route('me.dashboard'))->assertSuccessful();
});
test('users without manage permission cannot start impersonation', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$editor = User::factory()->create();
$editor->assignRole('editor');
$customer = User::factory()->create();
$customer->assignRole('customer');
$this->actingAs($editor);
LivewireVolt::test('admin.users')
->assertDontSee('Login als User')
->call('loginAsUser', $customer->id)
->assertForbidden();
$this->assertAuthenticatedAs($editor);
$this->assertFalse(session()->has(UserImpersonation::SessionKey));
});
test('admin can impersonate accounts with admin access', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create();
$admin->assignRole('admin');
$targetAdmin = User::factory()->create([
'email' => 'target-admin@example.com',
]);
$targetAdmin->assignRole('admin');
$targetEditor = User::factory()->create([
'email' => 'target-editor@example.com',
]);
$targetEditor->assignRole('editor');
$this->actingAs($admin);
LivewireVolt::test('admin.users')
->set('search', 'target-admin@example.com')
->call('loginAsUser', $targetAdmin->id)
->assertRedirect(route('me.dashboard'));
$this->assertAuthenticatedAs($targetAdmin);
expect(session(UserImpersonation::SessionKey))->toBe($admin->id);
$this->get(route('dashboard'))->assertRedirect(route('me.dashboard'));
$this->actingAs($admin);
session()->forget(UserImpersonation::SessionKey);
LivewireVolt::test('admin.users')
->set('search', 'target-editor@example.com')
->call('loginAsUser', $targetEditor->id)
->assertRedirect(route('me.dashboard'));
$this->assertAuthenticatedAs($targetEditor);
expect(session(UserImpersonation::SessionKey))->toBe($admin->id);
$this->get(route('dashboard'))->assertRedirect(route('me.dashboard'));
});
test('impersonated user can return to the admin account', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create();
$admin->assignRole('admin');
$customer = User::factory()->create();
$customer->assignRole('customer');
$this->actingAs($customer)
->withSession([UserImpersonation::SessionKey => $admin->id])
->post(route('admin.impersonate.leave'))
->assertRedirect(route('admin.users.index'))
->assertSessionHas('status', 'Erfolgreich zurück zum Admin-Account gewechselt.')
->assertSessionMissing(UserImpersonation::SessionKey);
$this->assertAuthenticatedAs($admin);
});
test('invalid impersonation session is cleared on leave', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$customer = User::factory()->create();
$customer->assignRole('customer');
$this->actingAs($customer)
->withSession([UserImpersonation::SessionKey => 999999])
->post(route('admin.impersonate.leave'))
->assertRedirect(route('me.dashboard'))
->assertSessionMissing(UserImpersonation::SessionKey);
$this->assertAuthenticatedAs($customer);
});

File diff suppressed because it is too large Load diff