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

View file

@ -0,0 +1,72 @@
<?php
use App\Models\Category;
use App\Models\Company;
use App\Models\NewsletterSubscription;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
test('legacy api key requests receive gone response', function () {
/** @var TestCase $this */
$this->getJson('/api/v1/categories?api_key=legacy-key')
->assertStatus(410)
->assertJsonPath('message', 'Legacy API keys are no longer supported.');
});
test('api user can read only own companies', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$ownCompany = Company::factory()->presseecho()->create(['name' => 'Eigene Firma']);
$otherCompany = Company::factory()->businessportal24()->create(['name' => 'Fremde Firma']);
$user->companies()->attach($ownCompany->id, ['role' => 'owner']);
Sanctum::actingAs($user, ['companies:read']);
$this->getJson('/api/v1/companies')
->assertOk()
->assertSee('Eigene Firma')
->assertDontSee('Fremde Firma');
$this->getJson("/api/v1/companies/{$otherCompany->id}")
->assertForbidden();
});
test('api user can list categories with press release read ability', function () {
/** @var TestCase $this */
$user = User::factory()->create();
Category::factory()->withTranslations()->create();
Sanctum::actingAs($user, ['press-releases:read']);
$this->getJson('/api/v1/categories')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'portal', 'translations'],
],
]);
});
test('api user can subscribe an email to newsletter', function () {
/** @var TestCase $this */
$user = User::factory()->create();
Sanctum::actingAs($user, ['newsletter:subscribe']);
$this->postJson('/api/v1/newsletter/subscribe', [
'portal' => 'presseecho',
'email' => 'newsletter@example.com',
'first_name' => 'Max',
'last_name' => 'Muster',
])
->assertCreated()
->assertJsonPath('data.email', 'newsletter@example.com')
->assertJsonPath('data.is_confirmed', false);
expect(NewsletterSubscription::withoutGlobalScopes()
->where('email', 'newsletter@example.com')
->where('portal', 'presseecho')
->exists())->toBeTrue();
});

View file

@ -0,0 +1,81 @@
<?php
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
test('api user can create list update and delete own press releases', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$company = Company::factory()->presseecho()->create();
$category = Category::factory()->withTranslations()->create();
$user->companies()->attach($company->id, ['role' => 'owner']);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
$createResponse = $this->postJson('/api/v1/press-releases', [
'company_id' => $company->id,
'category_id' => $category->id,
'language' => 'de',
'title' => 'Neue API Pressemitteilung',
'text' => 'Inhalt der Pressemitteilung',
'status' => 'draft',
]);
$createResponse
->assertCreated()
->assertJsonPath('data.title', 'Neue API Pressemitteilung')
->assertJsonPath('data.portal', 'presseecho');
$pressReleaseId = PressRelease::withoutGlobalScopes()
->where('title', 'Neue API Pressemitteilung')
->value('id');
$this->getJson('/api/v1/press-releases')
->assertOk()
->assertJsonPath('data.0.id', $pressReleaseId);
$this->patchJson("/api/v1/press-releases/{$pressReleaseId}", [
'title' => 'Aktualisierte API Pressemitteilung',
])
->assertOk()
->assertJsonPath('data.title', 'Aktualisierte API Pressemitteilung');
$this->deleteJson("/api/v1/press-releases/{$pressReleaseId}")
->assertNoContent();
expect(PressRelease::withoutGlobalScopes()->withTrashed()->find($pressReleaseId)?->trashed())->toBeTrue();
});
test('api write ability is required to create press releases', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$company = Company::factory()->presseecho()->create();
$category = Category::factory()->withTranslations()->create();
$user->companies()->attach($company->id, ['role' => 'owner']);
Sanctum::actingAs($user, ['press-releases:read']);
$this->postJson('/api/v1/press-releases', [
'company_id' => $company->id,
'category_id' => $category->id,
'language' => 'de',
'title' => 'Nicht erlaubt',
'text' => 'Inhalt',
])->assertForbidden();
});
test('api user cannot access another users press release', function () {
/** @var TestCase $this */
$owner = User::factory()->create();
$otherUser = User::factory()->create();
$pressRelease = PressRelease::factory()->create(['user_id' => $owner->id]);
Sanctum::actingAs($otherUser, ['press-releases:read']);
$this->getJson("/api/v1/press-releases/{$pressRelease->id}")
->assertForbidden();
});

View file

@ -0,0 +1,119 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
test('api user can upload list and delete own press release images', function () {
/** @var TestCase $this */
Storage::fake('public');
$user = User::factory()->create();
$pressRelease = PressRelease::factory()->create([
'user_id' => $user->id,
'status' => PressReleaseStatus::Draft->value,
]);
Sanctum::actingAs($user, ['press-releases:read', 'press-release-images:write']);
$uploadResponse = $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [
'image' => UploadedFile::fake()->image('press-release.jpg', 1200, 800)->size(5120),
'title' => 'Pressefoto',
'is_preview' => true,
]);
$uploadResponse
->assertCreated()
->assertJsonPath('data.title', 'Pressefoto')
->assertJsonPath('data.is_preview', true)
->assertJsonPath('data.width', 1200)
->assertJsonPath('data.height', 800)
->assertJsonStructure(['data' => ['variants', 'urls']]);
$image = PressReleaseImage::query()->firstOrFail();
expect(Storage::disk('public')->exists($image->path))->toBeTrue();
expect($image->variants)->toBeArray();
expect($image->variants)->toHaveKeys(['thumb', 'medium', 'large']);
foreach ($image->variants as $variantPath) {
expect(Storage::disk('public')->exists($variantPath))->toBeTrue();
}
$this->getJson("/api/v1/press-releases/{$pressRelease->id}/images")
->assertOk()
->assertJsonPath('data.0.id', $image->id)
->assertJsonPath('data.0.url', asset('storage/'.ltrim($image->path, '/')));
$this->getJson("/api/v1/press-releases/{$pressRelease->id}")
->assertOk()
->assertJsonPath('data.images.0.id', $image->id);
$variantPaths = $image->variants ?? [];
$this->deleteJson("/api/v1/press-release-images/{$image->id}")
->assertNoContent();
expect(Storage::disk('public')->exists($image->path))->toBeFalse();
foreach ($variantPaths as $variantPath) {
expect(Storage::disk('public')->exists($variantPath))->toBeFalse();
}
expect(PressReleaseImage::withTrashed()->find($image->id)?->trashed())->toBeTrue();
});
test('image upload is limited to eight megabytes', function () {
/** @var TestCase $this */
Storage::fake('public');
$user = User::factory()->create();
$pressRelease = PressRelease::factory()->create([
'user_id' => $user->id,
'status' => PressReleaseStatus::Draft->value,
]);
Sanctum::actingAs($user, ['press-release-images:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [
'image' => UploadedFile::fake()->image('too-large.jpg')->size(8193),
])->assertUnprocessable()
->assertInvalid(['image']);
});
test('api user cannot manage another users press release images', function () {
/** @var TestCase $this */
Storage::fake('public');
$owner = User::factory()->create();
$otherUser = User::factory()->create();
$pressRelease = PressRelease::factory()->create([
'user_id' => $owner->id,
'status' => PressReleaseStatus::Draft->value,
]);
Sanctum::actingAs($otherUser, ['press-releases:read', 'press-release-images:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [
'image' => UploadedFile::fake()->image('foreign.jpg'),
])->assertForbidden();
});
test('published press release images cannot be changed via api', function () {
/** @var TestCase $this */
Storage::fake('public');
$user = User::factory()->create();
$pressRelease = PressRelease::factory()->published()->create([
'user_id' => $user->id,
]);
Sanctum::actingAs($user, ['press-release-images:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [
'image' => UploadedFile::fake()->image('published.jpg'),
])->assertConflict()
->assertJsonPath('message', 'Only draft or rejected press releases may be edited.');
});

View file

@ -0,0 +1,78 @@
<?php
use App\Models\ApiUsageLog;
use App\Models\User;
use Tests\TestCase;
test('api requests are logged without storing bearer token contents', function () {
/** @var TestCase $this */
$user = User::factory()->create(['is_active' => true]);
$plainTextToken = $user->createToken('API Client', ['press-releases:read'])->plainTextToken;
$tokenId = $user->tokens()->value('id');
$this->withHeader('Authorization', "Bearer {$plainTextToken}")
->getJson('/api/v1/categories')
->assertOk();
$log = ApiUsageLog::query()->firstOrFail();
expect($log->user_id)->toBe($user->id)
->and($log->personal_access_token_id)->toBe($tokenId)
->and($log->method)->toBe('GET')
->and($log->path)->toBe('/api/v1/categories')
->and($log->status_code)->toBe(200)
->and(json_encode($log->toArray()))->not->toContain($plainTextToken);
});
test('legacy api key rejections are logged', function () {
/** @var TestCase $this */
$this->getJson('/api/v1/categories?api_key=legacy-secret')
->assertStatus(410);
$log = ApiUsageLog::query()->firstOrFail();
expect($log->user_id)->toBeNull()
->and($log->personal_access_token_id)->toBeNull()
->and($log->path)->toBe('/api/v1/categories')
->and($log->status_code)->toBe(410)
->and(json_encode($log->toArray()))->not->toContain('legacy-secret');
});
test('inactive users cannot use existing api tokens', function () {
/** @var TestCase $this */
$user = User::factory()->create(['is_active' => false]);
$plainTextToken = $user->createToken('Old API Client', ['press-releases:read'])->plainTextToken;
$this->withHeader('Authorization', "Bearer {$plainTextToken}")
->getJson('/api/v1/categories')
->assertForbidden()
->assertJsonPath('message', 'API access is disabled for inactive users.');
expect(ApiUsageLog::query()->value('status_code'))->toBe(403);
});
test('api rate limit is scoped to each personal access token', function () {
/** @var TestCase $this */
$user = User::factory()->create(['is_active' => true]);
$firstToken = $user->createToken('Busy Client', ['press-releases:read'])->plainTextToken;
$secondToken = $user->createToken('Other Client', ['press-releases:read'])->plainTextToken;
expect(strtok($firstToken, '|'))->not->toBe(strtok($secondToken, '|'));
for ($request = 1; $request <= 60; $request++) {
$this->withHeader('Authorization', "Bearer {$firstToken}")
->getJson('/api/v1/categories')
->assertOk();
}
$this->withHeader('Authorization', "Bearer {$firstToken}")
->getJson('/api/v1/categories')
->assertStatus(429)
->assertJsonPath('message', 'API rate limit exceeded.');
$this->flushHeaders();
$this->withHeader('Authorization', "Bearer {$secondToken}")
->getJson('/api/v1/categories')
->assertOk();
});

View file

@ -0,0 +1,13 @@
<?php
use Tests\TestCase;
test('api v1 documentation is publicly available', function () {
/** @var TestCase $this */
$this->get('/docs/api/v1')
->assertOk()
->assertHeader('content-type', 'application/yaml; charset=UTF-8')
->assertSee('openapi: 3.1.0', false)
->assertSee('/press-releases:', false)
->assertSee('Legacy API keys are no longer supported.', false);
});

View file

@ -0,0 +1,65 @@
<?php
use App\Models\ApiUsageLog;
use App\Models\User;
use App\Services\Api\ApiUsageReporter;
use Tests\TestCase;
test('it summarizes api usage logs', function () {
$user = User::factory()->create();
$token = $user->createToken('Reporting Token', ['press-releases:read'])->accessToken;
createApiUsageLog($user->id, $token->id, 'GET', '/api/v1/categories', 200, 12, now()->subMinutes(3));
createApiUsageLog($user->id, $token->id, 'GET', '/api/v1/categories', 200, 10, now()->subMinutes(2));
createApiUsageLog($user->id, $token->id, 'POST', '/api/v1/newsletter/subscribe', 422, 30, now()->subMinute());
createApiUsageLog(null, null, 'GET', '/api/v1/categories', 410, 5, now());
$report = app(ApiUsageReporter::class)->report(top: 5);
expect($report['summary'])->toMatchArray([
'total_requests' => 4,
'unique_users' => 1,
'unique_tokens' => 1,
'successful_requests' => 2,
'client_error_requests' => 2,
'server_error_requests' => 0,
])
->and($report['summary']['average_duration_ms'])->toBe(14.25)
->and($report['top_paths'][0])->toMatchArray([
'value' => '/api/v1/categories',
'requests' => 3,
])
->and($report['status_codes'][0])->toMatchArray([
'value' => 200,
'requests' => 2,
]);
});
test('api usage report command renders summary', function () {
/** @var TestCase $this */
$user = User::factory()->create();
createApiUsageLog($user->id, null, 'GET', '/api/v1/companies', 200, 20, now());
$this->artisan('api:usage-report', ['--no-report' => true])
->expectsOutput('API-Usage-Report')
->expectsOutput('Requests: 1')
->expectsOutput('Eindeutige User: 1')
->assertSuccessful();
});
function createApiUsageLog(?int $userId, ?int $tokenId, string $method, string $path, int $statusCode, int $durationMs, DateTimeInterface $requestedAt): ApiUsageLog
{
return ApiUsageLog::query()->create([
'user_id' => $userId,
'personal_access_token_id' => $tokenId,
'method' => $method,
'path' => $path,
'route_name' => null,
'status_code' => $statusCode,
'ip_address' => '127.0.0.1',
'user_agent' => 'Pest',
'duration_ms' => $durationMs,
'requested_at' => $requestedAt,
]);
}

View file

@ -2,14 +2,18 @@
use App\Models\User;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
test('login screen can be rendered', function () {
$response = $this->get('/login');
/** @var TestCase $this */
$portalUrl = rtrim((string) config('domains.domain_portal_url', 'http://presseportale.test'), '/');
$response = $this->get($portalUrl.'/login');
$response->assertStatus(200);
});
test('users can authenticate using the login screen', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$response = LivewireVolt::test('auth.login')
@ -22,9 +26,13 @@ test('users can authenticate using the login screen', function () {
->assertRedirect(route('dashboard', absolute: false));
$this->assertAuthenticated();
$user->refresh();
expect($user->last_login_at)->not->toBeNull();
expect($user->last_login_ip)->toBe('127.0.0.1');
});
test('users can not authenticate with invalid password', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$response = LivewireVolt::test('auth.login')
@ -38,6 +46,7 @@ test('users can not authenticate with invalid password', function () {
});
test('users can logout', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/logout');
@ -45,4 +54,4 @@ test('users can logout', function () {
$response->assertRedirect('/');
$this->assertGuest();
});
});

View file

@ -4,6 +4,19 @@ use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Laravel\Fortify\Features;
/**
* Fortify's emailVerification feature is intentionally disabled in
* config/fortify.php (Volt handles the verification notice instead).
* The tests below cover the Fortify-issued signed URL flow and become
* relevant again once the feature is re-enabled.
*/
beforeEach(function () {
if (! Features::enabled(Features::emailVerification())) {
$this->markTestSkipped('Fortify emailVerification feature is disabled.');
}
});
test('email verification screen can be rendered', function () {
$user = User::factory()->unverified()->create();
@ -44,4 +57,4 @@ test('email is not verified with invalid hash', function () {
$this->actingAs($user)->get($verificationUrl);
expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
});
});

View file

@ -0,0 +1,122 @@
<?php
use App\Mail\MagicLoginLink;
use App\Models\MagicLink;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
test('user can request a magic login link from login page', function () {
Mail::fake();
$user = User::factory()->create(['is_active' => true]);
LivewireVolt::test('auth.login')
->set('email', $user->email)
->call('sendMagicLink')
->assertHasNoErrors();
Mail::assertSent(MagicLoginLink::class, function (MagicLoginLink $mail) use ($user) {
return $mail->user->is($user) && str_contains($mail->loginUrl, '/magic-login/');
});
$magicLink = MagicLink::query()->firstOrFail();
expect($magicLink->user_id)->toBe($user->id);
expect($magicLink->token_hash)->toHaveLength(64);
});
test('admin can login with a valid magic link and lands on admin dashboard', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Mail::fake();
$user = User::factory()->create(['is_active' => true]);
$user->assignRole('admin');
LivewireVolt::test('auth.login')
->set('email', $user->email)
->call('sendMagicLink');
$sentMail = null;
Mail::assertSent(MagicLoginLink::class, function (MagicLoginLink $mail) use (&$sentMail) {
$sentMail = $mail;
return true;
});
expect($sentMail)->not->toBeNull();
/** @var MagicLoginLink $sentMail */
$this->get($sentMail->loginUrl)
->assertRedirect(route('dashboard', absolute: false));
$this->assertAuthenticatedAs($user);
expect(MagicLink::query()->firstOrFail()->consumed_at)->not->toBeNull();
$user->refresh();
expect($user->last_login_at)->not->toBeNull();
expect($user->last_login_ip)->toBe('127.0.0.1');
});
test('customer is redirected to me dashboard after magic link login', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Mail::fake();
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
LivewireVolt::test('auth.login')
->set('email', $customer->email)
->call('sendMagicLink');
$sentMail = null;
Mail::assertSent(MagicLoginLink::class, function (MagicLoginLink $mail) use (&$sentMail) {
$sentMail = $mail;
return true;
});
/** @var MagicLoginLink $sentMail */
$this->get($sentMail->loginUrl)
->assertRedirect(route('me.dashboard', absolute: false));
$this->assertAuthenticatedAs($customer);
});
test('expired magic link can not be used', function () {
/** @var TestCase $this */
$user = User::factory()->create(['is_active' => true]);
$plainToken = 'expired-token';
MagicLink::query()->create([
'user_id' => $user->id,
'token_hash' => hash('sha256', $plainToken),
'purpose' => 'login',
'expires_at' => now()->subMinute(),
]);
$this->get(route('magic-links.consume', ['token' => $plainToken]))
->assertRedirect(route('login', absolute: false));
$this->assertGuest();
});
test('consumed magic link can not be reused', function () {
/** @var TestCase $this */
$user = User::factory()->create(['is_active' => true]);
$plainToken = 'consumed-token';
MagicLink::query()->create([
'user_id' => $user->id,
'token_hash' => hash('sha256', $plainToken),
'purpose' => 'login',
'expires_at' => now()->addMinutes(5),
'consumed_at' => now()->subMinute(),
]);
$this->get(route('magic-links.consume', ['token' => $plainToken]))
->assertRedirect(route('login', absolute: false));
$this->assertGuest();
});

View file

@ -0,0 +1,66 @@
<?php
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Tests\TestCase;
test('active admin and editor users can access admin area', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$editor = User::factory()->create(['is_active' => true]);
$editor->assignRole('editor');
expect($admin->canAccessAdmin())->toBeTrue();
expect($editor->canAccessAdmin())->toBeTrue();
});
test('inactive users cannot access admin area', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => false]);
$admin->assignRole('admin');
expect($admin->canAccessAdmin())->toBeFalse();
});
test('super admin can access admin area without role', function () {
$user = User::factory()->create([
'is_active' => true,
'is_super_admin' => true,
]);
expect($user->canAccessAdmin())->toBeTrue();
});
test('active customer and staff users can access customer area', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
expect($customer->canAccessCustomer())->toBeTrue();
expect($admin->canAccessCustomer())->toBeTrue();
});
test('api-only and inactive users cannot access customer area', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$apiOnly = User::factory()->create(['is_active' => true]);
$apiOnly->assignRole('api-only');
$inactiveCustomer = User::factory()->create(['is_active' => false]);
$inactiveCustomer->assignRole('customer');
expect($apiOnly->canAccessCustomer())->toBeFalse();
expect($inactiveCustomer->canAccessCustomer())->toBeFalse();
});

View file

@ -0,0 +1,51 @@
<?php
use App\Models\Company;
use App\Models\Invoice;
use App\Models\PaymentOption;
use App\Models\UserPaymentOption;
use Database\Seeders\PaymentOptionSeeder;
use Illuminate\Support\Str;
use Tests\TestCase;
test('payment option seeder creates multilingual options', function () {
/** @var TestCase $this */
$this->seed(PaymentOptionSeeder::class);
$monthly = PaymentOption::query()
->where('article_number', 'PR-MONTHLY-001')
->firstOrFail();
expect($monthly->translations()->count())->toBe(2);
});
test('user payment option supports company pivot and payments relation', function () {
/** @var TestCase $this */
$userPaymentOption = UserPaymentOption::factory()->create();
$company = Company::query()->create([
'name' => 'Test Company '.Str::random(6),
'slug' => 'test-company-'.Str::lower(Str::random(8)),
]);
$userPaymentOption->companies()->attach($company->id, ['is_active' => true]);
$payment = $userPaymentOption->payments()->create([
'amount_cents' => 9900,
'currency' => 'EUR',
'status' => 'succeeded',
'stripe_charge_id' => 'ch_test_123456',
]);
$this->assertModelExists($payment);
expect($userPaymentOption->companies()->whereKey($company->id)->exists())->toBeTrue();
expect($userPaymentOption->payments()->count())->toBe(1);
});
test('invoice factory creates valid relation graph', function () {
/** @var TestCase $this */
$invoice = Invoice::factory()->create();
$this->assertModelExists($invoice);
expect($invoice->user)->not->toBeNull();
expect($invoice->invoiceBillingAddress)->not->toBeNull();
expect($invoice->userPayment)->not->toBeNull();
});

View file

@ -0,0 +1,26 @@
<?php
use App\Models\Category;
use Database\Seeders\CategorySeeder;
use Tests\TestCase;
test('category seeder creates multilingual categories', function () {
/** @var TestCase $this */
$this->seed(CategorySeeder::class);
$business = Category::query()
->whereHas('translations', function ($query) {
$query->where('locale', 'de')->where('slug', 'wirtschaft');
})
->firstOrFail();
expect($business->translations()->count())->toBe(2);
});
test('category seeder is idempotent for seeded slugs', function () {
/** @var TestCase $this */
$this->seed(CategorySeeder::class);
$this->seed(CategorySeeder::class);
expect(Category::query()->count())->toBe(3);
});

View file

@ -0,0 +1,354 @@
<?php
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Customer\CustomerCompanyContext;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
test('customer company switcher stores only accessible company context', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create([
'name' => 'Alpha GmbH',
]);
$inaccessibleCompany = Company::factory()->businessportal24()->create([
'name' => 'Fremde GmbH',
]);
$customer->companies()->attach($company->id, ['role' => 'responsible']);
$this->actingAs($customer);
LivewireVolt::test('customer.company-switcher')
->assertSee('Alpha GmbH')
->assertDontSee('Fremde GmbH')
->set('activeCompany', (string) $company->id);
expect(session(CustomerCompanyContext::SessionKey))->toBe($company->id);
LivewireVolt::test('customer.company-switcher')
->set('activeCompany', (string) $inaccessibleCompany->id);
expect(session(CustomerCompanyContext::SessionKey))->toBe($company->id);
});
test('customer company switcher links to selected company detail', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create([
'name' => 'Alpha GmbH',
]);
$customer->companies()->attach($company->id, ['role' => 'responsible']);
$this->actingAs($customer)
->withSession([CustomerCompanyContext::SessionKey => $company->id]);
LivewireVolt::test('customer.company-switcher')
->assertSee('Alpha GmbH')
->assertSee('Firma öffnen')
->assertSee(route('me.press-kits.show', $company->id, absolute: false));
});
test('customer press release list is filtered by selected company context', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create(['name' => 'Alpha GmbH']);
$otherCompany = Company::factory()->businessportal24()->create(['name' => 'Beta GmbH']);
$customer->companies()->attach($company->id, ['role' => 'owner']);
$customer->companies()->attach($otherCompany->id, ['role' => 'owner']);
PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'title' => 'Alpha Produktmeldung',
]);
PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => $otherCompany->id,
'title' => 'Beta Produktmeldung',
]);
$this->actingAs($customer)
->withSession([CustomerCompanyContext::SessionKey => $company->id]);
LivewireVolt::test('customer.press-releases.index')
->assertSee('Gefiltert auf Alpha GmbH')
->assertSee('Alpha Produktmeldung')
->assertDontSee('Beta Produktmeldung');
});
test('customer press release list can filter unassigned legacy press releases', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create(['name' => 'Alpha GmbH']);
$customer->companies()->attach($company->id, ['role' => 'owner']);
PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'title' => 'Alpha Firmenmeldung',
]);
PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => null,
'title' => 'Legacy ohne Firma',
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.index')
->set('companyFilter', 'unassigned')
->assertSee('Legacy ohne Firma')
->assertDontSee('Alpha Firmenmeldung');
});
test('customer dashboard is filtered by selected company context', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create(['name' => 'Alpha GmbH']);
$otherCompany = Company::factory()->businessportal24()->create(['name' => 'Beta GmbH']);
$customer->companies()->attach($company->id, ['role' => 'owner']);
$customer->companies()->attach($otherCompany->id, ['role' => 'owner']);
PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'title' => 'Alpha Dashboardmeldung',
]);
PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => $otherCompany->id,
'title' => 'Beta Dashboardmeldung',
]);
$this->actingAs($customer)
->withSession([CustomerCompanyContext::SessionKey => $company->id]);
LivewireVolt::test('customer.dashboard')
->assertSee('Übersicht für Alpha GmbH')
->assertSee('Alpha Dashboardmeldung')
->assertDontSee('Beta Dashboardmeldung');
});
test('customer dashboard shows data quality hints', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create(['name' => 'Alpha GmbH']);
$customer->companies()->attach($company->id, ['role' => 'owner']);
$this->actingAs($customer)
->withSession([CustomerCompanyContext::SessionKey => $company->id]);
LivewireVolt::test('customer.dashboard')
->assertSee('Datenqualität')
->assertSee('Profil unvollständig')
->assertSee('Rechnungsadresse fehlt')
->assertSee('Keine Pressekontakte hinterlegt');
});
test('customer dashboard links unassigned press release hint to filtered list', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create(['name' => 'Alpha GmbH']);
$customer->companies()->attach($company->id, ['role' => 'owner']);
PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => null,
'title' => 'Legacy ohne Firma',
]);
$this->actingAs($customer);
LivewireVolt::test('customer.dashboard')
->assertSee('Pressemitteilung ohne Firma')
->assertSee('company=unassigned');
});
test('create press release uses selected company context as default', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create(['name' => 'Alpha GmbH']);
$otherCompany = Company::factory()->businessportal24()->create(['name' => 'Beta GmbH']);
$customer->companies()->attach($company->id, ['role' => 'owner']);
$customer->companies()->attach($otherCompany->id, ['role' => 'owner']);
$this->actingAs($customer)
->withSession([CustomerCompanyContext::SessionKey => $otherCompany->id]);
LivewireVolt::test('customer.press-releases.create')
->assertSet('companyId', $otherCompany->id)
->assertSee('Beta GmbH');
});
test('customer can browse own press kits and open a press kit detail', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create([
'name' => 'Alpha Pressemappe',
'email' => 'presse@alpha.test',
]);
$otherCompany = Company::factory()->businessportal24()->create([
'name' => 'Fremde Pressemappe',
]);
$customer->companies()->attach($company->id, ['role' => 'responsible']);
Contact::factory()->create([
'company_id' => $company->id,
'first_name' => 'Paula',
'last_name' => 'Presse',
'email' => 'paula@example.test',
]);
PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'title' => 'Alpha Pressemeldung',
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-kits.index')
->assertSee('Alpha Pressemappe')
->assertDontSee('Fremde Pressemappe');
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
->assertSee('Alpha Pressemappe')
->assertSee('presse@alpha.test')
->assertSee('Paula Presse')
->assertSee('Alpha Pressemeldung')
->assertSee('Abrechnung')
->assertSee('Statistik');
expect(session(CustomerCompanyContext::SessionKey))->toBe($company->id);
});
test('customer cannot open inaccessible press kit detail', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$otherCompany = Company::factory()->businessportal24()->create([
'name' => 'Fremde Pressemappe',
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-kits.show', ['id' => $otherCompany->id])
->assertNotFound();
});
test('responsible customer can manage press kit contacts', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create([
'name' => 'Alpha Pressemappe',
]);
$customer->companies()->attach($company->id, ['role' => 'responsible']);
$this->actingAs($customer);
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
->call('startCreateContact')
->set('contactFirstName', 'Mara')
->set('contactLastName', 'Muster')
->set('contactResponsibility', 'Presse')
->set('contactEmail', 'mara@example.test')
->set('contactPhone', '+49 30 123')
->call('saveContact')
->assertHasNoErrors()
->assertSee('Pressekontakt wurde angelegt.');
$contact = Contact::query()->where('company_id', $company->id)->firstOrFail();
expect($contact->email)->toBe('mara@example.test');
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
->call('editContact', $contact->id)
->set('contactEmail', 'presse@example.test')
->call('saveContact')
->assertHasNoErrors()
->assertSee('Pressekontakt wurde aktualisiert.');
expect($contact->refresh()->email)->toBe('presse@example.test');
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
->call('deleteContact', $contact->id)
->assertSee('Pressekontakt wurde gelöscht.');
expect(Contact::query()->whereKey($contact->id)->exists())->toBeFalse();
});
test('member customer cannot manage press kit contacts', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create([
'name' => 'Alpha Pressemappe',
]);
$customer->companies()->attach($company->id, ['role' => 'member']);
$this->actingAs($customer);
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
->assertDontSee('Kontakt hinzufügen')
->call('startCreateContact')
->assertForbidden();
});
test('responsible customer can manage press kit company master data', function () {
/** @var TestCase $this */
Storage::fake('public');
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create([
'name' => 'Alte Pressemappe',
'email' => 'alt@example.test',
]);
$customer->companies()->attach($company->id, ['role' => 'responsible']);
$this->actingAs($customer);
$logo = UploadedFile::fake()->image('logo.png', 800, 400);
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
->call('startEditCompany')
->set('companyName', 'Neue Pressemappe GmbH')
->set('companyEmail', 'neu@example.test')
->set('companyPhone', '+49 30 456')
->set('companyWebsite', 'https://example.test')
->set('companyAddress', 'Neue Strasse 1')
->set('companyCountryCode', 'AT')
->set('companyDisableFooterCode', true)
->set('companyLogo', $logo)
->call('saveCompany')
->assertHasNoErrors()
->assertSee('Stammdaten wurden gespeichert.');
$company->refresh();
expect($company->name)->toBe('Neue Pressemappe GmbH');
expect($company->email)->toBe('neu@example.test');
expect($company->country_code)->toBe('AT');
expect($company->disable_footer_code)->toBeTrue();
expect($company->logo_path)->not->toBeNull();
expect($company->logo_variants)->toHaveKey('sq');
expect($company->logo_variants)->toHaveKey('wide');
expect(Storage::disk('public')->exists($company->logo_path))->toBeTrue();
});
test('member customer cannot manage press kit company master data', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create([
'name' => 'Alpha Pressemappe',
]);
$customer->companies()->attach($company->id, ['role' => 'member']);
$this->actingAs($customer);
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
->assertDontSee('Stammdaten bearbeiten')
->call('startEditCompany')
->assertForbidden();
});

View file

@ -0,0 +1,149 @@
<?php
use App\Enums\UserPaymentOptionStatus;
use App\Models\LegacyInvoice;
use App\Models\User;
use App\Models\UserPaymentOption;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
test('customer can create and revoke api tokens with selected abilities', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
UserPaymentOption::factory()->create([
'user_id' => $customer->id,
'status' => UserPaymentOptionStatus::Active->value,
'current_period_start' => now()->subDay(),
'current_period_end' => now()->addMonth(),
]);
$this->actingAs($customer);
LivewireVolt::test('customer.tokens')
->assertSee('API-Tokens')
->set('tokenName', 'Website Integration')
->set('selectedAbilities', ['press-releases:read', 'companies:read'])
->call('createToken')
->assertHasNoErrors()
->assertSee('Token wurde erstellt');
$token = $customer->tokens()->firstOrFail();
expect($token->name)->toBe('Website Integration');
expect($token->abilities)->toBe(['press-releases:read', 'companies:read']);
LivewireVolt::test('customer.tokens')
->call('revokeToken', $token->id)
->assertSee('Token wurde widerrufen.');
expect($customer->tokens()->count())->toBe(0);
});
test('customer cannot create api token without active payment access', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$this->actingAs($customer);
LivewireVolt::test('customer.tokens')
->set('tokenName', 'Website Integration')
->set('selectedAbilities', ['press-releases:read'])
->call('createToken')
->assertSee('API-Tokens werden erst freigeschaltet');
expect($customer->tokens()->count())->toBe(0);
});
test('customer sees only own legacy invoices', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$otherUser = User::factory()->create(['is_active' => true]);
LegacyInvoice::query()->create([
'legacy_portal' => 'presseecho',
'legacy_id' => 1001,
'user_id' => $customer->id,
'legacy_user_id' => 501,
'number' => 'RE-CUSTOMER-001',
'amount_cents' => 9900,
'total_cents' => 9900,
'status' => 'paid',
'invoice_date' => now()->subMonth(),
'paid_at' => now()->subMonth()->addDays(2),
'imported_at' => now(),
]);
LegacyInvoice::query()->create([
'legacy_portal' => 'businessportal24',
'legacy_id' => 1002,
'user_id' => $otherUser->id,
'legacy_user_id' => 502,
'number' => 'RE-OTHER-001',
'amount_cents' => 14900,
'total_cents' => 14900,
'status' => 'paid',
'invoice_date' => now()->subMonth(),
'paid_at' => now()->subMonth()->addDays(2),
'imported_at' => now(),
]);
$this->actingAs($customer);
LivewireVolt::test('customer.invoices')
->assertSee('RE-CUSTOMER-001')
->assertDontSee('RE-OTHER-001')
->assertSee('99,00 €');
});
test('customer can open generated legacy invoice pdf inline from archive data', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$invoice = LegacyInvoice::query()->create([
'legacy_portal' => 'presseecho',
'legacy_id' => 1003,
'user_id' => $customer->id,
'legacy_user_id' => 503,
'number' => 'RE-CUSTOMER-002',
'amount_cents' => 9900,
'total_cents' => 9900,
'status' => 'paid',
'invoice_date' => now()->subMonth(),
'due_date' => now()->subMonth()->addDays(14),
'paid_at' => now()->subMonth()->addDays(2),
'payment_method' => 'SPK_Berlin',
'raw_snapshot' => [
'number' => 'RE-CUSTOMER-002',
'service_period_begin_date' => now()->subYear()->toDateString(),
'service_period_end_date' => now()->toDateString(),
],
'pdf_payload' => [
'billing_address' => [
'name' => 'Customer GmbH',
'address' => 'Kundenstrasse 1',
'postal_code' => '10115',
'city' => 'Berlin',
'country_name' => 'Deutschland',
],
],
'imported_at' => now(),
]);
$this->actingAs($customer);
LivewireVolt::test('customer.invoices')
->assertSee('Hinweis zu Rechnungen')
->assertSee('Rechnungsadresse im Profil pflegen')
->assertSee('Öffnen');
$this->get(route('me.invoices.pdf', $invoice))
->assertSuccessful()
->assertHeader('content-type', 'application/pdf')
->assertHeader('content-disposition', 'inline; filename="Presseecho-RNr-RE-CUSTOMER-002.pdf"');
expect($invoice->refresh()->pdf_generated_at)->not->toBeNull();
});

View file

@ -0,0 +1,313 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
use App\Models\LegacyInvoice;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
test('customer can update own profile fields', function () {
/** @var TestCase $this */
$customer = User::factory()->create([
'is_active' => true,
'name' => 'Original Name',
'language' => 'de',
]);
$this->actingAs($customer);
LivewireVolt::test('customer.profile')
->assertSee('Rechnungsadresse')
->set('name', 'Neuer Anzeigename')
->set('firstName', 'Max')
->set('lastName', 'Mustermann')
->set('language', 'en')
->set('address', 'Musterfirma GmbH, Musterstrasse 1, 10115 Berlin')
->set('countryCode', 'AT')
->set('billingName', 'Musterfirma GmbH')
->set('billingAddress1', 'Musterstrasse 1')
->set('billingPostalCode', '10115')
->set('billingCity', 'Berlin')
->set('billingCountryCode', 'DE')
->set('taxIdNumber', 'ATU12345678')
->set('showStats', true)
->call('saveProfile')
->assertHasNoErrors();
$customer->refresh();
expect($customer->name)->toBe('Neuer Anzeigename');
expect($customer->language)->toBe('en');
expect($customer->profile?->first_name)->toBe('Max');
expect($customer->profile?->last_name)->toBe('Mustermann');
expect($customer->profile?->address)->toBe('Musterfirma GmbH, Musterstrasse 1, 10115 Berlin');
expect($customer->profile?->country_code)->toBe('AT');
expect($customer->profile?->tax_id_number)->toBe('ATU12345678');
expect($customer->profile?->show_stats)->toBeTrue();
expect($customer->billingAddress?->name)->toBe('Musterfirma GmbH');
expect($customer->billingAddress?->address1)->toBe('Musterstrasse 1');
expect($customer->billingAddress?->postal_code)->toBe('10115');
expect($customer->billingAddress?->city)->toBe('Berlin');
expect($customer->billingAddress?->country_code)->toBe('DE');
});
test('customer can update an owned company and upload a logo with variants', function () {
/** @var TestCase $this */
Storage::fake('public');
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create([
'owner_user_id' => $customer->id,
'name' => 'Old Co',
]);
$customer->companies()->attach($company->id, ['role' => 'owner']);
$logo = UploadedFile::fake()->image('logo.png', 800, 400);
$this->actingAs($customer);
LivewireVolt::test('customer.profile')
->set('editableCompanyId', $company->id)
->set('companyName', 'New Co GmbH')
->set('companyEmail', 'contact@example.test')
->set('companyWebsite', 'https://example.test')
->set('companyCountryCode', 'CH')
->set('companyLogo', $logo)
->call('saveCompany')
->assertHasNoErrors();
$company->refresh();
expect($company->name)->toBe('New Co GmbH');
expect($company->email)->toBe('contact@example.test');
expect($company->country_code)->toBe('CH');
expect($company->logo_path)->not->toBeNull();
expect($company->logo_variants)->toHaveKey('sq');
expect($company->logo_variants)->toHaveKey('wide');
expect(Storage::disk('public')->exists($company->logo_path))->toBeTrue();
expect(Storage::disk('public')->exists($company->logo_variants['sq']))->toBeTrue();
expect(Storage::disk('public')->exists($company->logo_variants['wide']))->toBeTrue();
});
test('customer cannot edit a company they do not own or co-manage', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$otherCompany = Company::factory()->presseecho()->create([
'owner_user_id' => User::factory()->create()->id,
]);
$customer->companies()->attach($otherCompany->id, ['role' => 'member']);
$this->actingAs($customer);
LivewireVolt::test('customer.profile')
->set('editableCompanyId', $otherCompany->id)
->set('companyName', 'Hijacked')
->call('saveCompany');
$otherCompany->refresh();
expect($otherCompany->name)->not->toBe('Hijacked');
});
test('customer can edit a company they own directly without pivot membership', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create([
'owner_user_id' => $customer->id,
'name' => 'Owner Company',
]);
$this->actingAs($customer);
LivewireVolt::test('customer.profile')
->set('editableCompanyId', $company->id)
->set('companyName', 'Owner Company Updated')
->call('saveCompany')
->assertHasNoErrors();
expect($company->refresh()->name)->toBe('Owner Company Updated');
});
test('customer security page renders password and email forms', function () {
/** @var TestCase $this */
$customer = User::factory()->create([
'is_active' => true,
'password' => bcrypt('current-password'),
]);
$this->actingAs($customer);
LivewireVolt::test('customer.security')
->assertSee('Konto-Sicherheit')
->assertSee('Letzter Login')
->assertSee('Aktive Sessions')
->assertSee('Passwort ändern')
->assertSee('E-Mail-Adresse ändern')
->assertSee('Zwei-Faktor-Authentifizierung');
});
test('customer can change own password through security page', function () {
/** @var TestCase $this */
$customer = User::factory()->create([
'is_active' => true,
'password' => bcrypt('current-password'),
]);
$this->actingAs($customer);
LivewireVolt::test('customer.security')
->set('current_password', 'current-password')
->set('password', 'new-strong-password')
->set('password_confirmation', 'new-strong-password')
->call('updatePassword')
->assertHasNoErrors();
$customer->refresh();
expect(Hash::check('new-strong-password', $customer->password))->toBeTrue();
});
test('press release policy denies access to other users releases', function () {
/** @var TestCase $this */
$owner = User::factory()->create(['is_active' => true]);
$intruder = User::factory()->create(['is_active' => true]);
$pr = PressRelease::factory()->create([
'user_id' => $owner->id,
'status' => 'draft',
]);
expect($intruder->can('view', $pr))->toBeFalse();
expect($intruder->can('update', $pr))->toBeFalse();
expect($owner->can('view', $pr))->toBeTrue();
expect($owner->can('update', $pr))->toBeTrue();
});
test('me press release routes only resolve own press releases even for admins', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$otherUser = User::factory()->create(['is_active' => true]);
$otherPressRelease = PressRelease::factory()->create([
'user_id' => $otherUser->id,
'status' => 'draft',
]);
$this->actingAs($admin);
$this->get(route('me.press-releases.show', $otherPressRelease->id))
->assertNotFound();
$this->get(route('me.press-releases.edit', $otherPressRelease->id))
->assertNotFound();
});
test('customer press releases derive portal from selected company', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create();
$customer->companies()->attach($company->id, ['role' => 'owner']);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.create')
->set('companyId', $company->id)
->set('portal', Portal::Businessportal24->value)
->set('categoryId', Category::factory()->create()->id)
->set('title', 'Neue Meldung fuer Presseecho')
->set('text', str_repeat('Dies ist ein ausreichend langer Testtext. ', 3))
->call('save', 'draft')
->assertHasNoErrors();
$pressRelease = PressRelease::query()->where('user_id', $customer->id)->firstOrFail();
expect($pressRelease->portal)->toBe(Portal::Presseecho);
});
test('customer press release detail shows assigned contacts and status history', function () {
/** @var TestCase $this */
$customer = User::factory()->create([
'is_active' => true,
'name' => 'Kunden Nutzer',
]);
$company = Company::factory()->presseecho()->create([
'name' => 'Alpha GmbH',
]);
$customer->companies()->attach($company->id, ['role' => 'owner']);
$pressRelease = PressRelease::factory()->forPortal(Portal::Presseecho)->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'title' => 'Alpha Detailmeldung',
'status' => PressReleaseStatus::Review->value,
'hits' => 1234,
]);
$contact = Contact::factory()->create([
'company_id' => $company->id,
'portal' => Portal::Presseecho->value,
'first_name' => 'Paula',
'last_name' => 'Presse',
'responsibility' => 'PR Managerin',
'email' => 'paula@example.test',
]);
$pressRelease->contacts()->attach($contact->id);
PressReleaseStatusLog::query()->create([
'press_release_id' => $pressRelease->id,
'changed_by_user_id' => $customer->id,
'from_status' => PressReleaseStatus::Draft->value,
'to_status' => PressReleaseStatus::Review->value,
'reason' => 'Zur Prüfung eingereicht',
'source' => 'customer',
'created_at' => now(),
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pressRelease->id])
->assertSee('Alpha Detailmeldung')
->assertSee('Zugeordnete Pressekontakte')
->assertSee('Paula Presse')
->assertSee('paula@example.test')
->assertSee('Status & Verlauf')
->assertSee('In Pruefung')
->assertSee('1.234')
->assertSee('Zur Prüfung eingereicht')
->assertSee('durch Kunden Nutzer');
});
test('legacy invoice policy denies access to invoices of other users', function () {
/** @var TestCase $this */
$owner = User::factory()->create(['is_active' => true]);
$intruder = User::factory()->create(['is_active' => true]);
$invoice = LegacyInvoice::query()->create([
'legacy_portal' => 'presseecho',
'legacy_id' => 9001,
'user_id' => $owner->id,
'legacy_user_id' => 9001,
'number' => 'RE-OWN-9001',
'amount_cents' => 9900,
'total_cents' => 9900,
'status' => 'paid',
'invoice_date' => now()->subMonth(),
'imported_at' => now(),
]);
expect($owner->can('view', $invoice))->toBeTrue();
expect($intruder->can('view', $invoice))->toBeFalse();
expect($intruder->can('downloadPdf', $invoice))->toBeFalse();
});

View file

@ -1,16 +1,67 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Tests\TestCase;
test('guests are redirected to the login page', function () {
$response = $this->get('/dashboard');
$response->assertRedirect('/login');
/** @var TestCase $this */
$response = $this->get(config('domains.domain_portal_url').'/dashboard');
$response->assertRedirect(route('login'));
});
test('authenticated users can visit the dashboard', function () {
$user = User::factory()->create();
$this->actingAs($user);
test('admins can visit the dashboard', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$response = $this->get('/dashboard');
$response->assertStatus(200);
});
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
$response = $this->get(config('domains.domain_portal_url').'/dashboard');
$response->assertSuccessful();
});
test('dashboard renders aggregated press release stats', 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();
PressRelease::factory()
->published()
->create([
'user_id' => $admin->id,
'company_id' => $company->id,
'title' => 'Veröffentlichte Dashboard PM',
]);
PressRelease::factory()
->inReview()
->create([
'user_id' => $admin->id,
'company_id' => $company->id,
'title' => 'Review Dashboard PM',
]);
PressRelease::factory()->create([
'user_id' => $admin->id,
'company_id' => $company->id,
'status' => PressReleaseStatus::Draft->value,
]);
$this->get(config('domains.domain_portal_url').'/dashboard')
->assertSuccessful()
->assertSee('3')
->assertSee('1 pub')
->assertSee('1 prüf')
->assertSee('1 entwurf')
->assertSee('Review Dashboard PM');
});

View file

@ -1,7 +1,5 @@
<?php
test('returns a successful response', function () {
$response = $this->get('/');
$response->assertStatus(200);
});
test('application health endpoint responds', function () {
$this->get('/up')->assertStatus(200);
});

View file

@ -0,0 +1,87 @@
<?php
use App\Enums\Portal;
use App\Models\Company;
use App\Models\PressRelease;
use Tests\TestCase;
test('press release slug is unique within portal and language scope', function () {
/** @var TestCase $this */
$base = (new PressRelease)->generateUniqueSlug('Test Mitteilung', [
'portal' => Portal::Presseecho->value,
'language' => 'de',
]);
expect($base)->toBe('test-mitteilung');
PressRelease::factory()->create([
'portal' => Portal::Presseecho->value,
'language' => 'de',
'slug' => 'test-mitteilung',
]);
$next = (new PressRelease)->generateUniqueSlug('Test Mitteilung', [
'portal' => Portal::Presseecho->value,
'language' => 'de',
]);
expect($next)->toBe('test-mitteilung-2');
// Same slug allowed in another portal
$foreign = (new PressRelease)->generateUniqueSlug('Test Mitteilung', [
'portal' => Portal::Businessportal24->value,
'language' => 'de',
]);
expect($foreign)->toBe('test-mitteilung');
// Same slug allowed in another language
$en = (new PressRelease)->generateUniqueSlug('Test Mitteilung', [
'portal' => Portal::Presseecho->value,
'language' => 'en',
]);
expect($en)->toBe('test-mitteilung');
});
test('press release editing keeps own slug stable', function () {
/** @var TestCase $this */
$pr = PressRelease::factory()->create([
'portal' => Portal::Presseecho->value,
'language' => 'de',
'slug' => 'meine-mitteilung',
]);
$regenerated = $pr->generateUniqueSlug('Meine Mitteilung', [
'portal' => Portal::Presseecho->value,
'language' => 'de',
]);
expect($regenerated)->toBe('meine-mitteilung');
});
test('company slug is unique within portal scope', function () {
/** @var TestCase $this */
Company::factory()->create([
'portal' => Portal::Presseecho->value,
'slug' => 'acme-gmbh',
]);
$slug = (new Company)->generateUniqueSlug('Acme GmbH', ['portal' => Portal::Presseecho->value]);
expect($slug)->toBe('acme-gmbh-2');
$other = (new Company)->generateUniqueSlug('Acme GmbH', ['portal' => Portal::Businessportal24->value]);
expect($other)->toBe('acme-gmbh');
});
test('blank source falls back to model defined fallback', function () {
/** @var TestCase $this */
$prSlug = (new PressRelease)->generateUniqueSlug('---', [
'portal' => Portal::Presseecho->value,
'language' => 'de',
]);
expect($prSlug)->toBe('pressemitteilung');
$coSlug = (new Company)->generateUniqueSlug('!!!', ['portal' => Portal::Presseecho->value]);
expect($coSlug)->toBe('company');
});

View file

@ -0,0 +1,51 @@
<?php
use App\Services\Api\LegacyApiAccessLogAnalyzer;
use Tests\TestCase;
test('it summarizes legacy api access log entries without exposing api keys', function () {
$logPath = storage_path('framework/testing/legacy-api-access.log');
if (! is_dir(dirname($logPath))) {
mkdir(dirname($logPath), 0775, true);
}
file_put_contents($logPath, implode(PHP_EOL, [
'203.0.113.10 - - [28/Apr/2026:10:00:00 +0200] "GET /pressrelease/list?api_key=secret-one HTTP/1.1" 200 123 "-" "Client A"',
'203.0.113.10 - - [28/Apr/2026:10:01:00 +0200] "POST /pressrelease/create?api_key=secret-one HTTP/1.1" 201 456 "-" "Client A"',
'198.51.100.20 - - [28/Apr/2026:10:02:00 +0200] "GET /company/list?api_key=secret-two HTTP/1.1" 200 321 "-" "Client B"',
'198.51.100.20 - - [28/Apr/2026:10:03:00 +0200] "GET /preise HTTP/1.1" 200 999 "-" "Browser"',
]));
$report = app(LegacyApiAccessLogAnalyzer::class)->analyze([$logPath]);
expect($report['summary']['total_lines'])->toBe(4)
->and($report['summary']['matched_requests'])->toBe(3)
->and($report['summary']['legacy_key_requests'])->toBe(3)
->and($report['summary']['unique_client_ips'])->toBe(2)
->and($report['summary']['unique_api_key_fingerprints'])->toBe(2)
->and($report['endpoints'])->toHaveKey('pressrelease/list')
->and($report['endpoints'])->toHaveKey('pressrelease/create')
->and($report['endpoints'])->toHaveKey('company/list')
->and(json_encode($report))->not->toContain('secret-one');
});
test('legacy api access log command renders a summary', function () {
/** @var TestCase $this */
$logPath = storage_path('framework/testing/legacy-api-access-command.log');
if (! is_dir(dirname($logPath))) {
mkdir(dirname($logPath), 0775, true);
}
file_put_contents($logPath, '203.0.113.10 - - [28/Apr/2026:10:00:00 +0200] "GET /newsletter/subscribe?api_key=secret-one HTTP/1.1" 200 123 "-" "Client A"');
$this->artisan('api:analyze-legacy-access-logs', [
'paths' => [$logPath],
'--no-report' => true,
])
->expectsOutput('Legacy-API-Access-Log-Auswertung')
->expectsOutput('Legacy-API-Requests: 1')
->expectsOutput('Requests mit api_key: 1')
->assertSuccessful();
});

View file

@ -0,0 +1,87 @@
<?php
use App\Enums\RegistrationType;
use App\Models\LegacyInvoice;
use App\Models\User;
use App\Services\Api\LegacyApiCustomerReporter;
use Tests\TestCase;
test('it classifies legacy api customers by active state and latest paid invoice', function () {
$eligible = User::factory()->create([
'email' => 'eligible@example.com',
'registration_type' => RegistrationType::ApiUser->value,
'is_active' => true,
]);
$needsReview = User::factory()->create([
'email' => 'review@example.com',
'registration_type' => RegistrationType::ApiUser->value,
'is_active' => true,
]);
$blockedInactive = User::factory()->create([
'email' => 'inactive@example.com',
'registration_type' => RegistrationType::ApiUser->value,
'is_active' => false,
]);
$blockedUnpaid = User::factory()->create([
'email' => 'unpaid@example.com',
'registration_type' => RegistrationType::ApiUser->value,
'is_active' => true,
]);
createLegacyApiCustomerInvoice($eligible, 1001, 'paid', now()->subMonth());
createLegacyApiCustomerInvoice($blockedInactive, 1002, 'paid', now()->subMonth());
createLegacyApiCustomerInvoice($blockedUnpaid, 1003, 'paid', now()->subMonths(2));
createLegacyApiCustomerInvoice($blockedUnpaid, 1004, 'reminder', now()->subMonth());
$report = app(LegacyApiCustomerReporter::class)->report();
expect($report['summary'])->toMatchArray([
'total_candidates' => 4,
'eligible' => 1,
'needs_review' => 1,
'blocked' => 2,
]);
$customers = collect($report['customers'])->keyBy('email');
expect($customers['eligible@example.com']['classification'])->toBe('eligible')
->and($customers['review@example.com']['classification'])->toBe('needs_review')
->and($customers['inactive@example.com']['classification'])->toBe('blocked')
->and($customers['unpaid@example.com']['classification'])->toBe('blocked')
->and($customers['unpaid@example.com']['latest_legacy_invoice']['status'])->toBe('reminder');
});
test('legacy api customers report command renders summary', function () {
/** @var TestCase $this */
$user = User::factory()->create([
'email' => 'api-customer@example.com',
'registration_type' => RegistrationType::ApiUser->value,
]);
createLegacyApiCustomerInvoice($user, 2001, 'paid', now());
$this->artisan('api:legacy-customers-report', ['--no-report' => true])
->expectsOutput('Legacy-API-Kundenreport')
->expectsOutput('Kandidaten: 1')
->expectsOutput('Freigabefähig: 1')
->assertSuccessful();
});
function createLegacyApiCustomerInvoice(User $user, int $legacyId, string $status, DateTimeInterface $invoiceDate): LegacyInvoice
{
return LegacyInvoice::query()->create([
'legacy_portal' => 'businessportal24',
'legacy_id' => $legacyId,
'user_id' => $user->id,
'legacy_user_id' => $user->legacy_id ?? $user->id,
'number' => "LEG-{$legacyId}",
'total_cents' => 4900,
'status' => $status,
'invoice_date' => $invoiceDate,
'paid_at' => $status === 'paid' ? $invoiceDate : null,
'imported_at' => now(),
]);
}

View file

@ -0,0 +1,249 @@
<?php
use App\Models\LegacyImportMap;
use App\Models\LegacyInvoice;
use App\Models\User;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
test('legacy invoice archive imports full invoice payload and user mapping', function () {
/** @var TestCase $this */
configureLegacyInvoiceConnection();
$user = User::factory()->create();
LegacyImportMap::query()->create([
'legacy_portal' => 'presseecho',
'legacy_table' => 'sf_guard_user',
'legacy_id' => 501,
'target_table' => 'users',
'target_id' => $user->id,
'imported_at' => now(),
]);
insertLegacyInvoice([
'id' => 1001,
'user_id' => 501,
'user_payment_id' => 901,
'billing_address_id' => 701,
'number' => 'PE-1001',
'status' => 'paid',
'amount' => 119.00,
'invoice_date' => '2024-01-10',
'due_date' => '2024-01-24',
'payment_method' => 'SPK_Berlin',
'pay_date' => '2024-01-12',
]);
$this->artisan('legacy:archive-invoices', [
'--portal' => 'presseecho',
'--no-report' => true,
])
->assertSuccessful()
->expectsOutputToContain('[presseecho] Quelle: 1 | ohne User-Mapping: 0 | PDF-Payload: 1');
$invoice = LegacyInvoice::query()->where('legacy_id', 1001)->firstOrFail();
expect($invoice->user_id)->toBe($user->id)
->and($invoice->status)->toBe('paid')
->and($invoice->amount_cents)->toBe(11900)
->and(data_get($invoice->raw_snapshot, 'number'))->toBe('PE-1001')
->and(data_get($invoice->pdf_payload, 'billing_address.name'))->toBe('Muster GmbH')
->and(data_get($invoice->pdf_payload, 'user_payment.status'))->toBe('finished')
->and(data_get($invoice->pdf_payload, 'payment_option_translation.name'))->toBe('Legacy Pressepaket');
});
test('legacy invoice archive keeps unmapped invoices and reports them', function () {
/** @var TestCase $this */
configureLegacyInvoiceConnection();
insertLegacyInvoice([
'id' => 1002,
'user_id' => 999,
'user_payment_id' => 901,
'billing_address_id' => 701,
'number' => 'PE-1002',
'status' => 'open',
'amount' => 49.00,
'invoice_date' => '2024-02-10',
'due_date' => '2024-02-24',
'payment_method' => 'Invoice',
'pay_date' => '0000-00-00',
]);
$this->artisan('legacy:archive-invoices', [
'--portal' => 'presseecho',
'--no-report' => true,
])
->assertSuccessful()
->expectsOutputToContain('[presseecho] Quelle: 1 | ohne User-Mapping: 1 | PDF-Payload: 1');
$invoice = LegacyInvoice::query()->where('legacy_id', 1002)->firstOrFail();
expect($invoice->user_id)->toBeNull()
->and($invoice->legacy_user_id)->toBe(999)
->and($invoice->status)->toBe('open')
->and($invoice->paid_at)->toBeNull();
});
function configureLegacyInvoiceConnection(): void
{
Config::set('database.connections.mysql_presseecho', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
'foreign_key_constraints' => false,
]);
DB::purge('mysql_presseecho');
Schema::connection('mysql_presseecho')->create('invoice', function (Blueprint $table): void {
$table->integer('id')->primary();
$table->integer('user_id');
$table->integer('user_payment_id');
$table->integer('billing_address_id');
$table->string('number');
$table->string('status');
$table->integer('reminder_count')->nullable();
$table->date('next_reminder_date')->nullable();
$table->decimal('amount', 10, 2);
$table->boolean('is_netto')->default(false);
$table->boolean('is_media')->default(true);
$table->date('invoice_date');
$table->date('due_date');
$table->date('service_period_begin_date')->nullable();
$table->date('service_period_end_date')->nullable();
$table->string('payment_method')->nullable();
$table->date('pay_date')->nullable();
$table->dateTime('created_at');
$table->dateTime('updated_at');
});
Schema::connection('mysql_presseecho')->create('invoice_billing_address', function (Blueprint $table): void {
$table->integer('id')->primary();
$table->integer('salutation_id')->nullable();
$table->string('title')->nullable();
$table->string('name');
$table->text('address')->nullable();
$table->string('address1')->nullable();
$table->string('address2')->nullable();
$table->string('postal_code')->nullable();
$table->string('city')->nullable();
$table->integer('country_id')->nullable();
$table->string('country_name')->nullable();
$table->dateTime('created_at');
$table->dateTime('updated_at');
});
Schema::connection('mysql_presseecho')->create('user_payment', function (Blueprint $table): void {
$table->integer('id')->primary();
$table->integer('user_payment_option_id');
$table->decimal('amount', 10, 2);
$table->string('status');
$table->dateTime('created_at');
$table->dateTime('updated_at');
});
Schema::connection('mysql_presseecho')->create('user_payment_option', function (Blueprint $table): void {
$table->integer('id')->primary();
$table->integer('user_id');
$table->integer('payment_option_id');
$table->integer('coupon_id')->nullable();
$table->string('status');
$table->date('valid_until_date')->nullable();
$table->date('next_due_date')->nullable();
$table->dateTime('created_at');
$table->dateTime('updated_at');
});
Schema::connection('mysql_presseecho')->create('payment_option', function (Blueprint $table): void {
$table->integer('id')->primary();
$table->string('article_number');
$table->string('type');
$table->decimal('price', 10, 2);
$table->boolean('is_hidden')->default(false);
$table->string('event_name_prefix')->nullable();
$table->boolean('activate_on_first_payment')->default(false);
});
Schema::connection('mysql_presseecho')->create('payment_option_translation', function (Blueprint $table): void {
$table->integer('id');
$table->string('name');
$table->text('description')->nullable();
$table->string('lang', 5);
});
DB::connection('mysql_presseecho')->table('invoice_billing_address')->insert([
'id' => 701,
'salutation_id' => 1,
'title' => null,
'name' => 'Muster GmbH',
'address' => 'Musterstrasse 1',
'address1' => null,
'address2' => null,
'postal_code' => '10115',
'city' => 'Berlin',
'country_id' => 177,
'country_name' => 'Deutschland',
'created_at' => '2024-01-01 00:00:00',
'updated_at' => '2024-01-01 00:00:00',
]);
DB::connection('mysql_presseecho')->table('user_payment')->insert([
'id' => 901,
'user_payment_option_id' => 801,
'amount' => 119.00,
'status' => 'finished',
'created_at' => '2024-01-01 00:00:00',
'updated_at' => '2024-01-01 00:00:00',
]);
DB::connection('mysql_presseecho')->table('user_payment_option')->insert([
'id' => 801,
'user_id' => 501,
'payment_option_id' => 601,
'coupon_id' => null,
'status' => 'finished',
'valid_until_date' => '2024-12-31',
'next_due_date' => null,
'created_at' => '2024-01-01 00:00:00',
'updated_at' => '2024-01-01 00:00:00',
]);
DB::connection('mysql_presseecho')->table('payment_option')->insert([
'id' => 601,
'article_number' => 'PK-LEGACY',
'type' => 'recurring',
'price' => 119.00,
'is_hidden' => false,
'event_name_prefix' => 'paymentoption.legacy',
'activate_on_first_payment' => true,
]);
DB::connection('mysql_presseecho')->table('payment_option_translation')->insert([
'id' => 601,
'name' => 'Legacy Pressepaket',
'description' => null,
'lang' => 'de',
]);
}
/**
* @param array<string, mixed> $overrides
*/
function insertLegacyInvoice(array $overrides): void
{
DB::connection('mysql_presseecho')->table('invoice')->insert(array_merge([
'reminder_count' => null,
'next_reminder_date' => null,
'is_netto' => false,
'is_media' => true,
'service_period_begin_date' => '2024-01-01',
'service_period_end_date' => '2024-12-31',
'created_at' => '2024-01-01 00:00:00',
'updated_at' => '2024-01-01 00:00:00',
], $overrides));
}

View file

@ -0,0 +1,130 @@
<?php
use App\Models\User;
use App\Services\Import\ImportContext;
use App\Services\Import\UserImporter;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
test('legacy user import creates linked profile data from sf guard user profile', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Config::set('database.connections.mysql_presseecho', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
'foreign_key_constraints' => false,
]);
DB::purge('mysql_presseecho');
Schema::connection('mysql_presseecho')->create('sf_guard_user', function (Blueprint $table): void {
$table->integer('id')->primary();
$table->string('username')->nullable();
$table->boolean('is_active')->default(true);
$table->boolean('is_super_admin')->default(false);
$table->timestamp('last_login')->nullable();
$table->string('ip_address')->nullable();
$table->timestamp('created_at')->nullable();
$table->timestamp('updated_at')->nullable();
});
Schema::connection('mysql_presseecho')->create('sf_guard_user_profile', function (Blueprint $table): void {
$table->integer('user_id')->primary();
$table->string('email');
$table->integer('salutation_id')->nullable();
$table->string('title')->nullable();
$table->string('first_name')->nullable();
$table->string('last_name')->nullable();
$table->string('address', 1000)->nullable();
$table->integer('country_id')->nullable();
$table->string('phone')->nullable();
$table->date('birthdate')->nullable();
$table->string('language')->nullable();
$table->string('backlink_url')->nullable();
$table->boolean('show_stats')->default(false);
$table->timestamp('validation_date')->nullable();
$table->timestamp('contract_date')->nullable();
$table->string('registration_type')->nullable();
$table->string('validate')->nullable();
$table->string('tax_id_number')->nullable();
$table->boolean('tax_exempt')->default(false);
$table->string('tax_exempt_reason', 1000)->nullable();
$table->boolean('disable_footer_code')->default(false);
});
Schema::connection('mysql_presseecho')->create('sf_guard_user_group', function (Blueprint $table): void {
$table->integer('user_id');
$table->integer('group_id');
});
DB::connection('mysql_presseecho')->table('sf_guard_user')->insert([
'id' => 42,
'username' => 'legacy-user',
'is_active' => true,
'is_super_admin' => false,
'last_login' => '2026-04-01 10:00:00',
'ip_address' => '127.0.0.1',
'created_at' => '2020-01-01 00:00:00',
'updated_at' => '2020-01-02 00:00:00',
]);
DB::connection('mysql_presseecho')->table('sf_guard_user_profile')->insert([
'user_id' => 42,
'email' => 'legacy-profile@example.com',
'salutation_id' => 2,
'title' => 'Dr.',
'first_name' => 'Legacy',
'last_name' => 'Profil',
'address' => 'Profilstrasse 7',
'country_id' => 177,
'phone' => '030123456',
'birthdate' => '1980-05-10',
'language' => 'de',
'backlink_url' => 'https://legacy.example.com',
'show_stats' => true,
'validation_date' => '2021-01-01 00:00:00',
'contract_date' => '2020-12-31 00:00:00',
'registration_type' => 'company',
'validate' => 'abc123',
'tax_id_number' => 'DE123456789',
'tax_exempt' => true,
'tax_exempt_reason' => 'Reverse Charge',
'disable_footer_code' => true,
]);
DB::connection('mysql_presseecho')->table('sf_guard_user_group')->insert([
'user_id' => 42,
'group_id' => 4,
]);
$result = app(UserImporter::class)->run(new ImportContext('presseecho', false, true));
expect($result->failed())->toBe(0);
expect($result->imported())->toBe(1);
$user = User::query()
->with('profile')
->where('email', 'legacy-profile@example.com')
->firstOrFail();
expect($user->profile)->not->toBeNull();
expect($user->profile?->salutation_key)->toBe('mrs');
expect($user->profile?->title)->toBe('Dr.');
expect($user->profile?->first_name)->toBe('Legacy');
expect($user->profile?->last_name)->toBe('Profil');
expect($user->profile?->address)->toBe('Profilstrasse 7');
expect($user->profile?->country_code)->toBe('DE');
expect($user->profile?->birthdate?->format('Y-m-d'))->toBe('1980-05-10');
expect($user->profile?->backlink_url)->toBe('https://legacy.example.com');
expect($user->profile?->show_stats)->toBeTrue();
expect($user->profile?->tax_id_number)->toBe('DE123456789');
expect($user->profile?->tax_exempt)->toBeTrue();
expect($user->profile?->tax_exempt_reason)->toBe('Reverse Charge');
expect($user->profile?->disable_footer_code)->toBeTrue();
});

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
test('legacy verify succeeds for an empty but consistent target database', function () {
/** @var TestCase $this */
Storage::fake('local');
$this->artisan('legacy:verify', ['--skip-legacy' => true])
->assertSuccessful()
->expectsOutputToContain('Legacy-DB-Checks werden übersprungen.')
->expectsOutputToContain('OK import_map_targets')
->expectsOutputToContain('OK target_foreign_keys');
expect(Storage::disk('local')->files('migration'))->toHaveCount(1);
});
test('legacy verify fails when an import map points to a missing target row', function () {
/** @var TestCase $this */
DB::table('legacy_import_map')->insert([
'legacy_portal' => 'presseecho',
'legacy_table' => 'sf_guard_user',
'legacy_id' => 123,
'target_table' => 'users',
'target_id' => 999_999,
'imported_at' => now(),
]);
$this->artisan('legacy:verify', [
'--skip-legacy' => true,
'--no-report' => true,
])
->assertExitCode(Command::FAILURE)
->expectsOutputToContain('FAIL import_map_targets');
});

View file

@ -0,0 +1,134 @@
<?php
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
test('legacy customer dashboard URL redirects to me dashboard', function () {
/** @var TestCase $this */
$this->get('/customer')
->assertRedirect('/admin/me');
});
test('legacy customer press releases URL redirects to me press releases', function () {
/** @var TestCase $this */
$this->get('/customer/press-releases')
->assertRedirect('/admin/me/press-releases');
$this->get('/customer/press-releases/create')
->assertRedirect('/admin/me/press-releases/create');
$this->get('/customer/press-releases/123')
->assertRedirect('/admin/me/press-releases/123');
$this->get('/customer/press-releases/123/edit')
->assertRedirect('/admin/me/press-releases/123/edit');
});
test('legacy customer settings URLs redirect to me area', function () {
/** @var TestCase $this */
$this->get('/customer/profile')->assertRedirect('/admin/me/profile');
$this->get('/customer/security')->assertRedirect('/admin/me/security');
$this->get('/customer/tokens')->assertRedirect('/admin/me/tokens');
$this->get('/customer/invoices')->assertRedirect('/admin/me/invoices');
$this->get('/customer/buchungen-add-ons')->assertRedirect('/admin/me/buchungen-add-ons');
$this->get('/customer/pressemappen')->assertRedirect('/admin/me/firmen');
$this->get('/customer/pressemappen/123')->assertRedirect('/admin/me/firmen/123');
});
test('me dashboard requires authentication', function () {
/** @var TestCase $this */
$this->get(route('me.dashboard'))
->assertRedirect(route('login'));
});
test('customer can access me dashboard but not admin dashboard', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$this->actingAs($customer);
$this->get(route('me.dashboard'))->assertSuccessful();
$this->get(route('dashboard'))->assertForbidden();
});
test('admin can access both panel dashboards', function () {
/** @var TestCase $this */
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
$this->get(route('dashboard'))->assertSuccessful();
$this->get(route('me.dashboard'))->assertSuccessful();
});
test('editor can access both panel dashboards', function () {
/** @var TestCase $this */
$editor = User::factory()->create(['is_active' => true]);
$editor->assignRole('editor');
$this->actingAs($editor);
$this->get(route('dashboard'))->assertSuccessful();
$this->get(route('me.dashboard'))->assertSuccessful();
});
test('inactive user cannot access panel', function () {
/** @var TestCase $this */
$user = User::factory()->create(['is_active' => false]);
$user->assignRole('customer');
$this->actingAs($user);
$this->get(route('me.dashboard'))->assertForbidden();
});
test('sidebar shows customer area for admins', function () {
/** @var TestCase $this */
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin)
->get(route('dashboard'))
->assertSee('Mein Bereich')
->assertSee('Administration');
});
test('sidebar hides admin area for customers', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$this->actingAs($customer)
->get(route('me.dashboard'))
->assertSee('Mein Bereich')
->assertSee('Buchungen &amp; Add-ons', false)
->assertSee('Finanzen')
->assertSee('Rechnungen')
->assertSee('Konto')
->assertSee('API &amp; Integrationen', false)
->assertDontSee('Administration')
->assertDontSee('Footer-Codes');
});
test('all me routes resolve to expected admin paths', function () {
expect(route('me.dashboard', absolute: false))->toBe('/admin/me');
expect(route('me.press-releases.index', absolute: false))->toBe('/admin/me/press-releases');
expect(route('me.press-releases.create', absolute: false))->toBe('/admin/me/press-releases/create');
expect(route('me.press-releases.show', ['id' => 5], absolute: false))->toBe('/admin/me/press-releases/5');
expect(route('me.press-releases.edit', ['id' => 5], absolute: false))->toBe('/admin/me/press-releases/5/edit');
expect(route('me.press-kits.index', absolute: false))->toBe('/admin/me/firmen');
expect(route('me.press-kits.show', ['id' => 5], absolute: false))->toBe('/admin/me/firmen/5');
expect(route('me.bookings.index', absolute: false))->toBe('/admin/me/buchungen-add-ons');
expect(route('me.invoices.index', absolute: false))->toBe('/admin/me/invoices');
expect(route('me.tokens.index', absolute: false))->toBe('/admin/me/tokens');
expect(route('me.profile', absolute: false))->toBe('/admin/me/profile');
expect(route('me.security', absolute: false))->toBe('/admin/me/security');
});

View file

@ -0,0 +1,81 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Mail\PressReleasePublished;
use App\Mail\PressReleaseRejected;
use App\Models\PressRelease;
use App\Services\PressRelease\BlacklistService;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseService;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
beforeEach(function (): void {
config()->set('blacklist.words', ['penis', 'casino royale']);
app()->forgetInstance(BlacklistService::class);
});
test('blacklist service finds banned word case insensitive and across word boundaries', function () {
$service = app(BlacklistService::class);
expect($service->matches('Hier kommt PENIS vor.'))->toBeTrue();
expect($service->matches('Wir spielen "Casino Royale" heute Abend.'))->toBeTrue();
expect($service->matches('Ein langer Begriff wie penistier ist erlaubt.'))->toBeFalse();
expect($service->matches('Eine ganz normale Pressemitteilung.'))->toBeFalse();
});
test('submitting a press release with banned word auto-rejects and notifies author', function () {
/** @var TestCase $this */
Mail::fake();
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Draft->value,
'title' => 'Tolle Neuigkeiten',
'text' => 'In diesem Text kommt das Wort penis prominent vor und das geht nicht.',
]);
expect(fn () => app(PressReleaseService::class)->submitForReview($pr))
->toThrow(BlacklistViolationException::class);
$pr->refresh();
expect($pr->status)->toBe(PressReleaseStatus::Rejected);
Mail::assertQueued(PressReleaseRejected::class);
});
test('publishing a clean press release succeeds when blacklist has no matches', function () {
/** @var TestCase $this */
Mail::fake();
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'title' => 'Saubere Mitteilung',
'text' => 'Dieser Inhalt ist völlig in Ordnung und enthält keine verbotenen Wörter.',
]);
app(PressReleaseService::class)->publish($pr);
$pr->refresh();
expect($pr->status)->toBe(PressReleaseStatus::Published);
Mail::assertQueued(PressReleasePublished::class);
});
test('publishing a press release with banned phrase auto-rejects', function () {
/** @var TestCase $this */
Mail::fake();
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'title' => 'Casino Royale Tour',
'text' => 'Erleben Sie die magische Atmosphäre von Casino Royale live.',
]);
expect(fn () => app(PressReleaseService::class)->publish($pr))
->toThrow(BlacklistViolationException::class);
$pr->refresh();
expect($pr->status)->toBe(PressReleaseStatus::Rejected);
Mail::assertQueued(PressReleaseRejected::class);
});

View file

@ -0,0 +1,74 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Auth\MagicLinkGenerator;
use Tests\TestCase;
test('preview link returns press release for valid unexpired token', function () {
/** @var TestCase $this */
$owner = User::factory()->create();
$pr = PressRelease::factory()->create([
'user_id' => $owner->id,
'status' => PressReleaseStatus::Draft->value,
'title' => 'Geheime Vorschau',
'text' => 'Vertraulicher Inhalt für unsere Partner.',
]);
$share = app(MagicLinkGenerator::class)->createPressReleaseShareLink($pr, $owner);
$this->get(route('press-releases.preview', ['token' => $share['plain_token']]))
->assertOk()
->assertSee('Geheime Vorschau')
->assertSee('Vertraulicher Inhalt');
});
test('preview link allows access to draft press release without authentication', function () {
/** @var TestCase $this */
$owner = User::factory()->create();
$pr = PressRelease::factory()->create([
'user_id' => $owner->id,
'status' => PressReleaseStatus::Review->value,
]);
$share = app(MagicLinkGenerator::class)->createPressReleaseShareLink($pr, $owner);
$response = $this->get(route('press-releases.preview', ['token' => $share['plain_token']]));
$response->assertOk();
expect(auth()->check())->toBeFalse();
});
test('expired preview link returns 410 gone', function () {
/** @var TestCase $this */
$owner = User::factory()->create();
$pr = PressRelease::factory()->create(['user_id' => $owner->id]);
$share = app(MagicLinkGenerator::class)->createPressReleaseShareLink($pr, $owner);
$share['magic_link']->update(['expires_at' => now()->subMinute()]);
$this->get(route('press-releases.preview', ['token' => $share['plain_token']]))
->assertStatus(410)
->assertSee('abgelaufen');
});
test('invalid token returns 404', function () {
/** @var TestCase $this */
$this->get(route('press-releases.preview', ['token' => str_repeat('a', 64)]))
->assertStatus(404)
->assertSee('ungültig');
});
test('preview link for soft deleted press release returns 404', function () {
/** @var TestCase $this */
$owner = User::factory()->create();
$pr = PressRelease::factory()->create(['user_id' => $owner->id]);
$share = app(MagicLinkGenerator::class)->createPressReleaseShareLink($pr, $owner);
$pr->forceDelete();
$this->get(route('press-releases.preview', ['token' => $share['plain_token']]))
->assertStatus(404);
});

View file

@ -0,0 +1,275 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Mail\PressReleasePublished;
use App\Mail\PressReleaseRejected;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Models\User;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\PressRelease\PressReleaseService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
test('submit for review logs a status change with customer source', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$pr = PressRelease::factory()->create([
'user_id' => $customer->id,
'status' => PressReleaseStatus::Draft->value,
]);
$this->actingAs($customer);
app(PressReleaseService::class)->submitForReview($pr);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review);
$log = PressReleaseStatusLog::query()->latest('id')->firstOrFail();
expect($log->press_release_id)->toBe($pr->id);
expect($log->from_status)->toBe(PressReleaseStatus::Draft);
expect($log->to_status)->toBe(PressReleaseStatus::Review);
expect($log->source)->toBe('customer');
expect($log->changed_by_user_id)->toBe($customer->id);
});
test('admin can reject a review press release with a reason', function () {
/** @var TestCase $this */
Mail::fake();
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$author = User::factory()->create(['is_active' => true]);
$author->assignRole('customer');
$pr = PressRelease::factory()->create([
'user_id' => $author->id,
'status' => PressReleaseStatus::Review->value,
]);
$this->actingAs($admin);
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
->set('rejectReason', 'Bitte überarbeiten Sie den werblichen Sprachstil.')
->call('reject')
->assertHasNoErrors();
$pr->refresh();
expect($pr->status)->toBe(PressReleaseStatus::Rejected);
$log = PressReleaseStatusLog::query()
->where('press_release_id', $pr->id)
->latest('id')
->firstOrFail();
expect($log->to_status)->toBe(PressReleaseStatus::Rejected);
expect($log->reason)->toBe('Bitte überarbeiten Sie den werblichen Sprachstil.');
expect($log->changed_by_user_id)->toBe($admin->id);
Mail::assertQueued(PressReleaseRejected::class, function (PressReleaseRejected $mail) use ($author) {
return $mail->user->is($author)
&& $mail->reason === 'Bitte überarbeiten Sie den werblichen Sprachstil.';
});
});
test('admin reject without reason fails validation', function () {
/** @var TestCase $this */
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
]);
$this->actingAs($admin);
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
->set('rejectReason', '')
->call('reject')
->assertHasErrors(['rejectReason']);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review);
});
test('admin publish notifies customer with me edit url', function () {
/** @var TestCase $this */
Mail::fake();
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$author = User::factory()->create(['is_active' => true]);
$author->assignRole('customer');
$pr = PressRelease::factory()->create([
'user_id' => $author->id,
'status' => PressReleaseStatus::Review->value,
]);
$this->actingAs($admin);
app(PressReleaseService::class)->publish($pr);
Mail::assertQueued(PressReleasePublished::class, function (PressReleasePublished $mail) use ($pr) {
return str_contains($mail->showUrl, '/admin/me/press-releases/'.$pr->id);
});
});
test('rejected mail uses customer edit url for customer recipients', function () {
/** @var TestCase $this */
Mail::fake();
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$author = User::factory()->create(['is_active' => true]);
$author->assignRole('customer');
$pr = PressRelease::factory()->create([
'user_id' => $author->id,
'status' => PressReleaseStatus::Review->value,
]);
$this->actingAs($admin);
app(PressReleaseService::class)->reject($pr, 'Test-Grund');
Mail::assertQueued(PressReleaseRejected::class, function (PressReleaseRejected $mail) use ($pr) {
return str_contains($mail->editUrl, '/admin/me/press-releases/'.$pr->id.'/edit');
});
});
test('admin can resubmit cycle creates two audit log entries', function () {
/** @var TestCase $this */
$author = User::factory()->create(['is_active' => true]);
$author->assignRole('customer');
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$pr = PressRelease::factory()->create([
'user_id' => $author->id,
'status' => PressReleaseStatus::Draft->value,
]);
$this->actingAs($author);
app(PressReleaseService::class)->submitForReview($pr);
$this->actingAs($admin);
app(PressReleaseService::class)->reject($pr->fresh(), 'Bitte korrigieren');
$this->actingAs($author);
app(PressReleaseService::class)->submitForReview($pr->fresh());
$logs = PressReleaseStatusLog::query()
->where('press_release_id', $pr->id)
->orderBy('id')
->get();
expect($logs)->toHaveCount(3);
expect($logs[0]->to_status)->toBe(PressReleaseStatus::Review);
expect($logs[1]->to_status)->toBe(PressReleaseStatus::Rejected);
expect($logs[2]->to_status)->toBe(PressReleaseStatus::Review);
});
test('customer show page surfaces latest reject reason prominently', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$pr = PressRelease::factory()->create([
'user_id' => $customer->id,
'status' => PressReleaseStatus::Rejected->value,
]);
PressReleaseStatusLog::query()->create([
'press_release_id' => $pr->id,
'changed_by_user_id' => null,
'from_status' => PressReleaseStatus::Review->value,
'to_status' => PressReleaseStatus::Rejected->value,
'reason' => 'Werbliche Sprache wurde markiert.',
'source' => 'admin',
'created_at' => now(),
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Werbliche Sprache wurde markiert.')
->assertSee('Erneut einreichen');
});
test('admin index publish goes through service and creates audit log', function () {
/** @var TestCase $this */
Mail::fake();
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
]);
$this->actingAs($admin);
LivewireVolt::test('admin.press-releases.index')
->call('publish', $pr->id)
->assertHasNoErrors();
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published);
$log = PressReleaseStatusLog::query()
->where('press_release_id', $pr->id)
->where('to_status', PressReleaseStatus::Published->value)
->firstOrFail();
expect($log->changed_by_user_id)->toBe($admin->id);
});
test('admin index reject from list creates audit log with default reason', function () {
/** @var TestCase $this */
Mail::fake();
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
]);
$this->actingAs($admin);
LivewireVolt::test('admin.press-releases.index')
->call('reject', $pr->id);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Rejected);
$log = PressReleaseStatusLog::query()
->where('press_release_id', $pr->id)
->latest('id')
->firstOrFail();
expect($log->reason)->not->toBeNull();
});
test('review counter helper returns correct number', function () {
/** @var TestCase $this */
PressRelease::factory()->count(3)->create(['status' => PressReleaseStatus::Review->value]);
PressRelease::factory()->count(2)->create(['status' => PressReleaseStatus::Draft->value]);
PressRelease::factory()->count(1)->create(['status' => PressReleaseStatus::Published->value]);
$count = app(AdminPerformanceCache::class)->pressReleaseReviewCount();
expect($count)->toBe(3);
});

View file

@ -0,0 +1,107 @@
<?php
use App\Enums\Portal;
use App\Models\Company;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
test('legacy company logos are copied to normalized storage and linked in database', function () {
/** @var TestCase $this */
Storage::fake('public');
Storage::disk('public')->put('presseecho/company/logo-a.jpg', 'logo-a');
Storage::disk('public')->put('presseecho/company/unused.jpg', 'unused');
$company = Company::factory()->create([
'portal' => Portal::Presseecho->value,
'legacy_portal' => Portal::Presseecho->value,
'legacy_id' => 123,
'logo_path' => 'logo-a.jpg',
]);
$this->artisan('legacy:sync-company-logos', [
'--portal' => Portal::Presseecho->value,
])
->assertSuccessful()
->expectsOutputToContain('presseecho: referenziert 1, kopiert 1, DB-Updates 1')
->expectsOutputToContain('ungenutzt 1');
$company->refresh();
expect($company->logo_path)->toBe("company-logos/presseecho/{$company->id}/logo-a.jpg");
expect(Storage::disk('public')->exists($company->logo_path))->toBeTrue();
});
test('legacy company logo sync dry run does not copy or update records', function () {
/** @var TestCase $this */
Storage::fake('public');
Storage::disk('public')->put('businessportal24/company/logo-b.png', 'logo-b');
$company = Company::factory()->create([
'portal' => Portal::Businessportal24->value,
'legacy_portal' => Portal::Businessportal24->value,
'legacy_id' => 456,
'logo_path' => 'logo-b.png',
]);
$this->artisan('legacy:sync-company-logos', [
'--portal' => Portal::Businessportal24->value,
'--dry-run' => true,
])
->assertSuccessful()
->expectsOutputToContain('(Dry-Run)');
$company->refresh();
expect($company->logo_path)->toBe('logo-b.png');
expect(Storage::disk('public')->exists("company-logos/businessportal24/{$company->id}/logo-b.png"))->toBeFalse();
});
test('legacy company logo sync reports missing referenced files', function () {
/** @var TestCase $this */
Storage::fake('public');
Company::factory()->create([
'portal' => Portal::Presseecho->value,
'legacy_portal' => Portal::Presseecho->value,
'legacy_id' => 789,
'logo_path' => 'missing.jpg',
]);
$this->artisan('legacy:sync-company-logos', [
'--portal' => Portal::Presseecho->value,
])
->assertFailed()
->expectsOutputToContain('Fehlt: presseecho/company/missing.jpg');
});
test('company logo url resolves normalized local legacy logo before external legacy url', function () {
/** @var TestCase $this */
Storage::fake('public');
$company = Company::factory()->create([
'portal' => Portal::Businessportal24->value,
'legacy_portal' => Portal::Businessportal24->value,
'legacy_id' => 321,
'logo_path' => 'https://legacy.example/company/logo-c.png',
]);
Storage::disk('public')->put("company-logos/businessportal24/{$company->id}/logo-c.png", 'logo-c');
expect($company->logoUrl())->toBe(asset("storage/company-logos/businessportal24/{$company->id}/logo-c.png"));
});
test('company logo url resolves storage-prefixed local path', function () {
/** @var TestCase $this */
Storage::fake('public');
$company = Company::factory()->create([
'portal' => Portal::Presseecho->value,
'logo_path' => '/storage/company-logos/presseecho/99/logo-d.png',
]);
Storage::disk('public')->put('company-logos/presseecho/99/logo-d.png', 'logo-d');
expect($company->logoUrl())->toBe(asset('storage/company-logos/presseecho/99/logo-d.png'));
});

View file

@ -0,0 +1,140 @@
<?php
use App\Enums\Portal;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
function fakeImageBytes(int $width = 800, int $height = 600): string
{
return UploadedFile::fake()->image('seed.jpg', $width, $height)->get();
}
test('legacy press release images are copied with variants and database is updated', function () {
/** @var TestCase $this */
Storage::fake('public');
Storage::disk('public')->put('presseecho/press_release/photo-a.jpg', fakeImageBytes(1600, 1200));
Storage::disk('public')->put('presseecho/press_release/unused.jpg', fakeImageBytes());
$pr = PressRelease::factory()->create([
'portal' => Portal::Presseecho->value,
'legacy_portal' => Portal::Presseecho->value,
]);
$image = PressReleaseImage::query()->create([
'press_release_id' => $pr->id,
'legacy_portal' => Portal::Presseecho->value,
'legacy_id' => 99,
'disk' => 'public',
'path' => 'photo-a.jpg',
]);
$this->artisan('legacy:sync-press-release-images', [
'--portal' => Portal::Presseecho->value,
])
->assertSuccessful()
->expectsOutputToContain('referenziert 1, kopiert 1');
$image->refresh();
expect($image->path)->toBe("press-releases/{$pr->id}/images/photo-a.jpg");
expect(Storage::disk('public')->exists($image->path))->toBeTrue();
expect($image->variants)->toBeArray();
expect($image->variants)->toHaveKeys(['thumb', 'medium', 'large']);
foreach ($image->variants as $variantPath) {
expect(Storage::disk('public')->exists($variantPath))->toBeTrue();
}
});
test('legacy press release image sync dry run does not copy or update anything', function () {
/** @var TestCase $this */
Storage::fake('public');
Storage::disk('public')->put('businessportal24/press_release/photo-b.jpg', fakeImageBytes());
$pr = PressRelease::factory()->create([
'portal' => Portal::Businessportal24->value,
'legacy_portal' => Portal::Businessportal24->value,
]);
$image = PressReleaseImage::query()->create([
'press_release_id' => $pr->id,
'legacy_portal' => Portal::Businessportal24->value,
'legacy_id' => 5,
'disk' => 'public',
'path' => 'photo-b.jpg',
]);
$this->artisan('legacy:sync-press-release-images', [
'--portal' => Portal::Businessportal24->value,
'--dry-run' => true,
])
->assertSuccessful()
->expectsOutputToContain('(Dry-Run)');
$image->refresh();
expect($image->path)->toBe('photo-b.jpg');
expect($image->variants)->toBeNull();
expect(Storage::disk('public')->exists("press-releases/{$pr->id}/images/photo-b.jpg"))->toBeFalse();
});
test('legacy press release image sync reports missing files', function () {
/** @var TestCase $this */
Storage::fake('public');
$pr = PressRelease::factory()->create([
'portal' => Portal::Presseecho->value,
'legacy_portal' => Portal::Presseecho->value,
]);
PressReleaseImage::query()->create([
'press_release_id' => $pr->id,
'legacy_portal' => Portal::Presseecho->value,
'legacy_id' => 7,
'disk' => 'public',
'path' => 'gone.jpg',
]);
$this->artisan('legacy:sync-press-release-images', [
'--portal' => Portal::Presseecho->value,
])
->assertFailed()
->expectsOutputToContain('Fehlt: presseecho/press_release/gone.jpg');
});
test('legacy press release image sync skips already migrated paths but generates missing variants', function () {
/** @var TestCase $this */
Storage::fake('public');
$pr = PressRelease::factory()->create([
'portal' => Portal::Presseecho->value,
'legacy_portal' => Portal::Presseecho->value,
]);
Storage::disk('public')->put("press-releases/{$pr->id}/images/already-here.jpg", fakeImageBytes(1200, 900));
$image = PressReleaseImage::query()->create([
'press_release_id' => $pr->id,
'legacy_portal' => Portal::Presseecho->value,
'legacy_id' => 11,
'disk' => 'public',
'path' => "press-releases/{$pr->id}/images/already-here.jpg",
'variants' => null,
]);
$this->artisan('legacy:sync-press-release-images', [
'--portal' => Portal::Presseecho->value,
])
->assertSuccessful()
->expectsOutputToContain('bereits synchron 1');
$image->refresh();
expect($image->variants)->toBeArray();
expect($image->variants)->toHaveKeys(['thumb', 'medium', 'large']);
});

View file

@ -0,0 +1,143 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use Tests\TestCase;
test('businessportal24 homepage renders the editorial shell', function () {
/** @var TestCase $this */
$this->get('https://businessportal24.test/')
->assertSuccessful()
->assertSee('businessportal', false)
->assertSeeText('Pressemitteilungen · DACH')
->assertSeeText('Aktuelle Meldungen')
->assertSeeText('Im Fokus')
->assertSeeText('Aktive Newsrooms')
->assertSeeText('Bleiben Sie informiert')
->assertSeeText('Veröffentlichen Sie Ihre Pressemitteilung')
->assertSeeText('Redaktioneller Qualitätsstandard')
->assertSeeText('AD-HOC')
->assertSeeText('Termine & Events')
->assertSeeText('Branchen-Index')
->assertSeeText('Heute im Fokus')
->assertSee('businessportal<span class="text-brand">24</span>', false)
->assertDontSeeText('führende Plattform')
->assertDontSeeText('maximale Reichweite')
->assertDontSeeText('Exklusiv-Interview');
});
test('businessportal24 homepage feed only shows published businessportal content', function () {
/** @var TestCase $this */
$category = Category::factory()->create([
'portal' => Portal::Businessportal24,
]);
$category->translations()->create([
'locale' => 'de',
'name' => 'Energie & Klima',
'slug' => 'energie-klima',
]);
$company = Company::factory()->businessportal24()->create([
'name' => 'Muster Energie GmbH',
]);
PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->for($category)
->for($company)
->create([
'title' => 'BusinessPortal24 sichtbare Meldung',
'slug' => 'businessportal24-sichtbare-meldung',
'published_at' => now()->subDay(),
]);
PressRelease::factory()
->published()
->forPortal(Portal::Both)
->for($category)
->for($company)
->create([
'title' => 'Gemeinsame sichtbare Meldung',
'slug' => 'gemeinsame-sichtbare-meldung',
'published_at' => now()->subDays(2),
]);
PressRelease::factory()
->published()
->forPortal(Portal::Presseecho)
->for($category)
->for($company)
->create([
'title' => 'Presseecho nicht sichtbar',
'slug' => 'presseecho-nicht-sichtbar',
'published_at' => now()->subDay(),
]);
PressRelease::factory()
->forPortal(Portal::Businessportal24)
->for($category)
->for($company)
->create([
'title' => 'Entwurf nicht sichtbar',
'slug' => 'entwurf-nicht-sichtbar',
'status' => PressReleaseStatus::Draft,
'published_at' => now()->subDay(),
]);
$this->get('https://businessportal24.test/')
->assertSuccessful()
->assertSeeText('BusinessPortal24 sichtbare Meldung')
->assertSeeText('Gemeinsame sichtbare Meldung')
->assertDontSeeText('Presseecho nicht sichtbar')
->assertDontSeeText('Entwurf nicht sichtbar');
});
test('businessportal24 homepage shows most read releases in the sidebar', function () {
/** @var TestCase $this */
$category = Category::factory()->create([
'portal' => Portal::Businessportal24,
]);
$category->translations()->create([
'locale' => 'de',
'name' => 'Technologie',
'slug' => 'technologie',
]);
$company = Company::factory()->businessportal24()->create([
'name' => 'Trending Corp',
]);
PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->for($category)
->for($company)
->create([
'title' => 'Meistgelesene BP24-Meldung',
'slug' => 'meistgelesene-bp24-meldung',
'hits' => 99999,
'published_at' => now()->subDays(3),
]);
PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->for($category)
->for($company)
->create([
'title' => 'Wenig gelesene BP24-Meldung',
'slug' => 'wenig-gelesene-bp24-meldung',
'hits' => 5,
'published_at' => now()->subDays(2),
]);
$this->get('https://businessportal24.test/')
->assertSuccessful()
->assertSeeText('Meistgelesen')
->assertSeeText('Meistgelesene BP24-Meldung')
->assertSeeText('Trending Corp');
});

View file

@ -0,0 +1,139 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use Tests\TestCase;
test('presseecho homepage renders the editorial shell', function () {
/** @var TestCase $this */
$this->get('https://presseecho.test/')
->assertSuccessful()
->assertSee('presseecho', false)
->assertSeeText('Pressemitteilungen · DACH')
->assertSeeText('Aktuelle Meldungen')
->assertSeeText('Im Fokus')
->assertSeeText('Aktive Newsrooms')
->assertSeeText('Bleiben Sie informiert')
->assertSeeText('Veröffentlichen Sie Ihre Pressemitteilung')
->assertSeeText('Redaktioneller Qualitätsstandard')
->assertSeeText('AD-HOC')
->assertSeeText('Termine & Events')
->assertSeeText('Branchen-Index')
->assertSeeText('Heute im Fokus');
});
test('presseecho homepage feed only shows published presseecho content', function () {
/** @var TestCase $this */
$category = Category::factory()->create([
'portal' => Portal::Presseecho,
]);
$category->translations()->create([
'locale' => 'de',
'name' => 'Energie & Klima',
'slug' => 'energie-klima',
]);
$company = Company::factory()->presseecho()->create([
'name' => 'Muster Energie GmbH',
]);
PressRelease::factory()
->published()
->forPortal(Portal::Presseecho)
->for($category)
->for($company)
->create([
'title' => 'Presseecho sichtbare Meldung',
'slug' => 'presseecho-sichtbare-meldung',
'published_at' => now()->subDay(),
]);
PressRelease::factory()
->published()
->forPortal(Portal::Both)
->for($category)
->for($company)
->create([
'title' => 'Gemeinsame sichtbare Meldung',
'slug' => 'gemeinsame-sichtbare-meldung',
'published_at' => now()->subDays(2),
]);
PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->for($category)
->for($company)
->create([
'title' => 'Businessportal24 nicht sichtbar',
'slug' => 'businessportal24-nicht-sichtbar',
'published_at' => now()->subDay(),
]);
PressRelease::factory()
->forPortal(Portal::Presseecho)
->for($category)
->for($company)
->create([
'title' => 'Entwurf nicht sichtbar',
'slug' => 'entwurf-nicht-sichtbar',
'status' => PressReleaseStatus::Draft,
'published_at' => now()->subDay(),
]);
$this->get('https://presseecho.test/')
->assertSuccessful()
->assertSeeText('Presseecho sichtbare Meldung')
->assertSeeText('Gemeinsame sichtbare Meldung')
->assertDontSeeText('Businessportal24 nicht sichtbar')
->assertDontSeeText('Entwurf nicht sichtbar');
});
test('presseecho homepage shows most read releases in the sidebar', function () {
/** @var TestCase $this */
$category = Category::factory()->create([
'portal' => Portal::Presseecho,
]);
$category->translations()->create([
'locale' => 'de',
'name' => 'Technologie',
'slug' => 'technologie',
]);
$company = Company::factory()->presseecho()->create([
'name' => 'Trending Corp',
]);
PressRelease::factory()
->published()
->forPortal(Portal::Presseecho)
->for($category)
->for($company)
->create([
'title' => 'Meistgelesene Presseecho-Meldung',
'slug' => 'meistgelesene-presseecho-meldung',
'hits' => 99999,
'published_at' => now()->subDays(3),
]);
PressRelease::factory()
->published()
->forPortal(Portal::Presseecho)
->for($category)
->for($company)
->create([
'title' => 'Wenig gelesene Presseecho-Meldung',
'slug' => 'wenig-gelesene-presseecho-meldung',
'hits' => 5,
'published_at' => now()->subDays(2),
]);
$this->get('https://presseecho.test/')
->assertSuccessful()
->assertSeeText('Meistgelesen')
->assertSeeText('Meistgelesene Presseecho-Meldung')
->assertSeeText('Trending Corp');
});

View file

@ -1,5 +1,9 @@
<?php
use App\Services\CurrentPortalContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/*
|--------------------------------------------------------------------------
| Test Case
@ -11,8 +15,13 @@
|
*/
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
pest()->extend(TestCase::class)
->use(RefreshDatabase::class)
->beforeEach(function () {
// PortalScope-Context zwischen Tests zurücksetzen, damit statischer
// State aus vorherigen Requests nicht in Volt-Tests durchschlägt.
CurrentPortalContext::clear();
})
->in('Feature');
/*