12-05-2026 Frontend dev
This commit is contained in:
parent
405df0a122
commit
5b8bdf4182
779 changed files with 480564 additions and 6241 deletions
156
tests/Feature/Admin/AdminCategoryManagementTest.php
Normal file
156
tests/Feature/Admin/AdminCategoryManagementTest.php
Normal 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();
|
||||
});
|
||||
162
tests/Feature/Admin/AdminFooterCodeManagementTest.php
Normal file
162
tests/Feature/Admin/AdminFooterCodeManagementTest.php
Normal 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();
|
||||
});
|
||||
111
tests/Feature/Admin/AdminLegacyInvoiceArchiveTest.php
Normal file
111
tests/Feature/Admin/AdminLegacyInvoiceArchiveTest.php
Normal 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();
|
||||
});
|
||||
29
tests/Feature/Admin/AdminPerformanceCacheTest.php
Normal file
29
tests/Feature/Admin/AdminPerformanceCacheTest.php
Normal 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();
|
||||
});
|
||||
78
tests/Feature/Admin/AdminPresetManagementTest.php
Normal file
78
tests/Feature/Admin/AdminPresetManagementTest.php
Normal 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();
|
||||
});
|
||||
150
tests/Feature/Admin/AdminPressReleaseActionsTest.php
Normal file
150
tests/Feature/Admin/AdminPressReleaseActionsTest.php
Normal 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?');
|
||||
});
|
||||
81
tests/Feature/Admin/AdminSlowRequestLoggingTest.php
Normal file
81
tests/Feature/Admin/AdminSlowRequestLoggingTest.php
Normal 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);
|
||||
});
|
||||
111
tests/Feature/Admin/AdminSlowRequestReportTest.php
Normal file
111
tests/Feature/Admin/AdminSlowRequestReportTest.php
Normal 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);
|
||||
}
|
||||
106
tests/Feature/Admin/CategoryIndexPerformanceTest.php
Normal file
106
tests/Feature/Admin/CategoryIndexPerformanceTest.php
Normal 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');
|
||||
});
|
||||
14
tests/Feature/Admin/PortalAssetManifestTest.php
Normal file
14
tests/Feature/Admin/PortalAssetManifestTest.php
Normal 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');
|
||||
});
|
||||
52
tests/Feature/Admin/RoleManagementTest.php
Normal file
52
tests/Feature/Admin/RoleManagementTest.php
Normal 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();
|
||||
});
|
||||
132
tests/Feature/Admin/UserImpersonationTest.php
Normal file
132
tests/Feature/Admin/UserImpersonationTest.php
Normal 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);
|
||||
});
|
||||
1255
tests/Feature/Admin/UserManagementTest.php
Normal file
1255
tests/Feature/Admin/UserManagementTest.php
Normal file
File diff suppressed because it is too large
Load diff
72
tests/Feature/Api/V1/CustomerDataApiTest.php
Normal file
72
tests/Feature/Api/V1/CustomerDataApiTest.php
Normal 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();
|
||||
});
|
||||
81
tests/Feature/Api/V1/PressReleaseApiTest.php
Normal file
81
tests/Feature/Api/V1/PressReleaseApiTest.php
Normal 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();
|
||||
});
|
||||
119
tests/Feature/Api/V1/PressReleaseImageApiTest.php
Normal file
119
tests/Feature/Api/V1/PressReleaseImageApiTest.php
Normal 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.');
|
||||
});
|
||||
78
tests/Feature/ApiAccessSecurityAndLoggingTest.php
Normal file
78
tests/Feature/ApiAccessSecurityAndLoggingTest.php
Normal 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();
|
||||
});
|
||||
13
tests/Feature/ApiDocumentationTest.php
Normal file
13
tests/Feature/ApiDocumentationTest.php
Normal 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);
|
||||
});
|
||||
65
tests/Feature/ApiUsageReporterTest.php
Normal file
65
tests/Feature/ApiUsageReporterTest.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
122
tests/Feature/Auth/MagicLinkLoginTest.php
Normal file
122
tests/Feature/Auth/MagicLinkLoginTest.php
Normal 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();
|
||||
});
|
||||
66
tests/Feature/Auth/UserAccessTest.php
Normal file
66
tests/Feature/Auth/UserAccessTest.php
Normal 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();
|
||||
});
|
||||
51
tests/Feature/Billing/BillingModelsTest.php
Normal file
51
tests/Feature/Billing/BillingModelsTest.php
Normal 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();
|
||||
});
|
||||
26
tests/Feature/Categories/CategorySeederTest.php
Normal file
26
tests/Feature/Categories/CategorySeederTest.php
Normal 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);
|
||||
});
|
||||
354
tests/Feature/CustomerCompanyContextTest.php
Normal file
354
tests/Feature/CustomerCompanyContextTest.php
Normal 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();
|
||||
});
|
||||
149
tests/Feature/CustomerPortalTest.php
Normal file
149
tests/Feature/CustomerPortalTest.php
Normal 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();
|
||||
});
|
||||
313
tests/Feature/CustomerProfileSecurityTest.php
Normal file
313
tests/Feature/CustomerProfileSecurityTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
87
tests/Feature/HasUniqueSlugTest.php
Normal file
87
tests/Feature/HasUniqueSlugTest.php
Normal 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');
|
||||
});
|
||||
51
tests/Feature/LegacyApiAccessLogAnalyzerTest.php
Normal file
51
tests/Feature/LegacyApiAccessLogAnalyzerTest.php
Normal 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();
|
||||
});
|
||||
87
tests/Feature/LegacyApiCustomerReporterTest.php
Normal file
87
tests/Feature/LegacyApiCustomerReporterTest.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
249
tests/Feature/LegacyInvoiceArchiveCommandTest.php
Normal file
249
tests/Feature/LegacyInvoiceArchiveCommandTest.php
Normal 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));
|
||||
}
|
||||
130
tests/Feature/LegacyUserProfileImportTest.php
Normal file
130
tests/Feature/LegacyUserProfileImportTest.php
Normal 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();
|
||||
});
|
||||
38
tests/Feature/LegacyVerifyCommandTest.php
Normal file
38
tests/Feature/LegacyVerifyCommandTest.php
Normal 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');
|
||||
});
|
||||
134
tests/Feature/PanelConsolidationTest.php
Normal file
134
tests/Feature/PanelConsolidationTest.php
Normal 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 & Add-ons', false)
|
||||
->assertSee('Finanzen')
|
||||
->assertSee('Rechnungen')
|
||||
->assertSee('Konto')
|
||||
->assertSee('API & 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');
|
||||
});
|
||||
81
tests/Feature/PressReleaseBlacklistTest.php
Normal file
81
tests/Feature/PressReleaseBlacklistTest.php
Normal 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);
|
||||
});
|
||||
74
tests/Feature/PressReleasePreviewTest.php
Normal file
74
tests/Feature/PressReleasePreviewTest.php
Normal 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);
|
||||
});
|
||||
275
tests/Feature/PressReleaseWorkflowTest.php
Normal file
275
tests/Feature/PressReleaseWorkflowTest.php
Normal 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);
|
||||
});
|
||||
107
tests/Feature/SyncCompanyLogosTest.php
Normal file
107
tests/Feature/SyncCompanyLogosTest.php
Normal 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'));
|
||||
});
|
||||
140
tests/Feature/SyncPressReleaseImagesTest.php
Normal file
140
tests/Feature/SyncPressReleaseImagesTest.php
Normal 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']);
|
||||
});
|
||||
143
tests/Feature/Web/Businessportal24HomeTest.php
Normal file
143
tests/Feature/Web/Businessportal24HomeTest.php
Normal 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');
|
||||
});
|
||||
139
tests/Feature/Web/PresseechoHomeTest.php
Normal file
139
tests/Feature/Web/PresseechoHomeTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
||||
/*
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue