22-05-2026 Optimierung der User und Admin Panels
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled

This commit is contained in:
Kevin Adametz 2026-05-22 11:18:59 +02:00
parent d2ba22c0cf
commit e8c47b7553
73 changed files with 10282 additions and 1546 deletions

View file

@ -102,10 +102,70 @@ test('admin opens generated legacy invoice pdf inline', function () {
LivewireVolt::test('admin.invoices.index')
->assertSee('Öffnen');
$this->get(route('admin.legacy-invoices.pdf', $invoice))
$response = $this->get(route('admin.legacy-invoices.pdf', $invoice));
$response
->assertSuccessful()
->assertHeader('content-type', 'application/pdf')
->assertHeader('content-disposition', 'inline; filename="Presseecho-RNr-PE-2003.pdf"');
expect($response->content())
->toContain('adametz.media')
->toContain('Rechnungsbetrag')
->toContain('/F2 21 Tf');
expect($invoice->refresh()->pdf_generated_at)->not->toBeNull();
});
test('admin opens legacy invoice pdf with stern layout for non media invoices', function () {
/** @var TestCase $this */
$invoice = LegacyInvoice::query()->create([
'legacy_portal' => 'businessportal24',
'legacy_id' => 2004,
'user_id' => null,
'legacy_user_id' => 504,
'number' => 'BP-2004',
'amount_cents' => 11900,
'total_cents' => 11900,
'status' => 'open',
'invoice_date' => now()->subMonth(),
'due_date' => now()->addDays(14),
'payment_method' => 'invoice',
'raw_snapshot' => [
'number' => 'BP-2004',
'is_media' => false,
'service_period_begin_date' => now()->subYear()->toDateString(),
'service_period_end_date' => now()->toDateString(),
],
'pdf_payload' => [
'invoice' => [
'is_media' => false,
'service_period_begin_date' => now()->subYear()->toDateString(),
'service_period_end_date' => now()->toDateString(),
],
'billing_address' => [
'name' => 'Stern Archiv GmbH',
'address' => 'Archivstrasse 2',
'postal_code' => '10719',
'city' => 'Berlin',
'country_name' => 'Deutschland',
],
'payment_option_translation' => [
'name' => 'Legacy Pressepaket',
],
],
'imported_at' => now(),
]);
$response = $this->get(route('admin.legacy-invoices.pdf', $invoice));
$response
->assertSuccessful()
->assertHeader('content-type', 'application/pdf')
->assertHeader('content-disposition', 'inline; filename="Businessportal24-RNr-BP-2004.pdf"');
expect($response->content())
->toContain('Stern Consulting GmbH')
->toContain('Hypo Vereinsbank')
->toContain('/F2 19 Tf');
});

View file

@ -95,8 +95,7 @@ test('admin can change archived press release back to another status', function
LivewireVolt::test('admin.press-releases.edit', ['id' => $pressRelease->id])
->set('targetStatus', PressReleaseStatus::Draft->value)
->call('changeStatus')
->assertHasNoErrors()
->assertSee('Status wurde auf');
->assertHasNoErrors();
$pressRelease->refresh();

View file

@ -0,0 +1,217 @@
<?php
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
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);
});
function makeFieldsAdmin(): User
{
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
return $admin;
}
test('admin create persistiert subtitle und boilerplate_override', function () {
/** @var TestCase $this */
$admin = makeFieldsAdmin();
$company = Company::factory()->presseecho()->create([
'boilerplate' => 'Firmen-Boilerplate (default).',
]);
$category = Category::factory()->create();
$this->actingAs($admin);
LivewireVolt::test('admin.press-releases.create')
->set('companyId', $company->id)
->set('portal', $company->portal->value)
->set('categoryId', $category->id)
->set('title', 'PM mit Subtitle und Boilerplate')
->set('subtitle', 'Eine knackige Dachzeile als Untertitel.')
->set('text', str_repeat('Inhalt eines Tests mit ausreichend Länge. ', 5))
->set('useBoilerplateOverride', true)
->set('boilerplateOverride', 'Override-Boilerplate nur für diese PM.')
->call('save')
->assertHasNoErrors();
$pr = PressRelease::query()->latest('id')->firstOrFail();
expect($pr->subtitle)->toBe('Eine knackige Dachzeile als Untertitel.');
expect($pr->boilerplate_override)->toBe('Override-Boilerplate nur für diese PM.');
});
test('admin create syncht ausgewählten Pressekontakt mit der PM', function () {
/** @var TestCase $this */
$admin = makeFieldsAdmin();
$company = Company::factory()->presseecho()->create();
$contact = Contact::factory()->for($company)->create([
'first_name' => 'Max',
'last_name' => 'Mustermann',
'portal' => $company->portal->value,
]);
$category = Category::factory()->create();
$this->actingAs($admin);
LivewireVolt::test('admin.press-releases.create')
->set('companyId', $company->id)
->set('portal', $company->portal->value)
->set('categoryId', $category->id)
->set('title', 'PM mit Kontakt')
->set('text', str_repeat('Inhalt eines Tests. ', 5))
->set('contactId', $contact->id)
->call('save')
->assertHasNoErrors();
$pr = PressRelease::query()->latest('id')->firstOrFail();
expect($pr->contacts()->pluck('contacts.id')->all())->toBe([$contact->id]);
});
test('admin create setzt default-Kontakt beim Firma-Wechsel', function () {
/** @var TestCase $this */
$admin = makeFieldsAdmin();
$company = Company::factory()->presseecho()->create();
$contact = Contact::factory()->for($company)->create([
'first_name' => 'Anna',
'last_name' => 'Aaron',
'portal' => $company->portal->value,
]);
$this->actingAs($admin);
LivewireVolt::test('admin.press-releases.create')
->set('companyId', $company->id)
->assertSet('contactId', $contact->id);
});
test('admin create addTag und removeTag schreiben keywords kommagetrennt', function () {
/** @var TestCase $this */
$admin = makeFieldsAdmin();
$this->actingAs($admin);
$component = LivewireVolt::test('admin.press-releases.create')
->call('addTag', 'Innovation')
->call('addTag', 'Mittelstand')
->call('addTag', 'Innovation')
->assertSet('keywords', 'Innovation, Mittelstand');
$component->call('removeTag', 'Innovation')
->assertSet('keywords', 'Mittelstand');
});
test('admin create lehnt zu langen Subtitle ab', function () {
/** @var TestCase $this */
$admin = makeFieldsAdmin();
$company = Company::factory()->presseecho()->create();
$category = Category::factory()->create();
$this->actingAs($admin);
LivewireVolt::test('admin.press-releases.create')
->set('companyId', $company->id)
->set('portal', $company->portal->value)
->set('categoryId', $category->id)
->set('title', 'Gültiger Titel hier')
->set('subtitle', str_repeat('a', 300))
->set('text', str_repeat('Inhalt eines Tests. ', 5))
->call('save')
->assertHasErrors(['subtitle']);
});
test('admin edit hydratisiert subtitle, boilerplate_override und Kontakt', function () {
/** @var TestCase $this */
$admin = makeFieldsAdmin();
$company = Company::factory()->presseecho()->create();
$contact = Contact::factory()->for($company)->create([
'first_name' => 'Pia',
'last_name' => 'Presse',
'portal' => $company->portal->value,
]);
$pr = PressRelease::factory()->create([
'company_id' => $company->id,
'subtitle' => 'Hydrierter Untertitel.',
'boilerplate_override' => 'Hydrierte Override-Boilerplate.',
]);
$pr->contacts()->sync([$contact->id]);
$this->actingAs($admin);
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
->assertSet('subtitle', 'Hydrierter Untertitel.')
->assertSet('boilerplateOverride', 'Hydrierte Override-Boilerplate.')
->assertSet('useBoilerplateOverride', true)
->assertSet('contactId', $contact->id);
});
test('admin edit speichert subtitle, boilerplate_override und Kontakt', function () {
/** @var TestCase $this */
$admin = makeFieldsAdmin();
$company = Company::factory()->presseecho()->create();
$contact = Contact::factory()->for($company)->create([
'portal' => $company->portal->value,
]);
$pr = PressRelease::factory()->create([
'company_id' => $company->id,
]);
$this->actingAs($admin);
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
->set('subtitle', 'Neuer Untertitel beim Bearbeiten.')
->set('useBoilerplateOverride', true)
->set('boilerplateOverride', 'Neue Override-Boilerplate beim Bearbeiten.')
->set('contactId', $contact->id)
->call('save')
->assertHasNoErrors();
$pr->refresh();
expect($pr->subtitle)->toBe('Neuer Untertitel beim Bearbeiten.');
expect($pr->boilerplate_override)->toBe('Neue Override-Boilerplate beim Bearbeiten.');
expect($pr->contacts()->pluck('contacts.id')->all())->toBe([$contact->id]);
});
test('admin edit zeigt Pre-Submit-Check-Berechnungen', function () {
/** @var TestCase $this */
$admin = makeFieldsAdmin();
$company = Company::factory()->presseecho()->create();
$category = Category::factory()->create();
$pr = PressRelease::factory()->create([
'company_id' => $company->id,
'category_id' => $category->id,
'title' => 'Vollständiger Titel mit ausreichend Zeichen für ok-Status',
'text' => str_repeat('Inhalt eines Tests mit ausreichend Länge. ', 25),
]);
$this->actingAs($admin);
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
->assertSee('Pre-Submit-Check')
->assertSee('Titel vorhanden')
->assertSee('Mindestlänge Fließtext erreicht')
->assertSee('Firma zugeordnet')
->assertSee('Kategorie gewählt')
->assertSee('Pressekontakt zugeordnet')
->assertSee('Themen-Tags vergeben');
});
test('admin edit zeigt Untertitel-Feld', function () {
/** @var TestCase $this */
$admin = makeFieldsAdmin();
$pr = PressRelease::factory()->create();
$this->actingAs($admin);
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
->assertSee('Untertitel')
->assertSee('Themen-Tags');
});

View file

@ -0,0 +1,141 @@
<?php
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Carbon;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
function makeAdmin(): User
{
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
return $admin;
}
test('admin create form persistiert scheduled_at und embargo_at', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
$admin = makeAdmin();
$company = Company::factory()->presseecho()->create();
$category = Category::factory()->create();
$this->actingAs($admin);
LivewireVolt::test('admin.press-releases.create')
->set('title', 'Admin Scheduled PM')
->set('text', str_repeat('Inhalt eines Tests. ', 5))
->set('companyId', $company->id)
->set('categoryId', $category->id)
->set('portal', $company->portal->value)
->set('publishMode', 'scheduled')
->set('scheduledAt', '2026-06-05T14:30')
->set('useEmbargo', true)
->set('embargoAt', '2026-06-10T08:00')
->call('save')
->assertHasNoErrors();
$pr = PressRelease::query()->latest('id')->firstOrFail();
expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-05 14:30:00');
expect($pr->embargo_at?->toDateTimeString())->toBe('2026-06-10 08:00:00');
});
test('admin create form lehnt scheduled_at in der Vergangenheit ab', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
$admin = makeAdmin();
$company = Company::factory()->presseecho()->create();
$category = Category::factory()->create();
$this->actingAs($admin);
LivewireVolt::test('admin.press-releases.create')
->set('title', 'Past Date PM')
->set('text', str_repeat('Inhalt eines Tests. ', 5))
->set('companyId', $company->id)
->set('categoryId', $category->id)
->set('portal', $company->portal->value)
->set('publishMode', 'scheduled')
->set('scheduledAt', '2026-05-15T10:00')
->call('save')
->assertHasErrors(['scheduledAt']);
});
test('admin edit hydriert scheduled_at und embargo_at', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
$admin = makeAdmin();
$this->actingAs($admin);
$pr = PressRelease::factory()->create([
'scheduled_at' => '2026-06-05 14:30:00',
'embargo_at' => '2026-06-10 08:00:00',
]);
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
->assertSet('publishMode', 'scheduled')
->assertSet('scheduledAt', '2026-06-05T14:30')
->assertSet('useEmbargo', true)
->assertSet('embargoAt', '2026-06-10T08:00');
});
test('admin edit persistiert scheduled_at und embargo_at', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
$admin = makeAdmin();
$this->actingAs($admin);
$pr = PressRelease::factory()->create([
'scheduled_at' => null,
'embargo_at' => null,
]);
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
->set('publishMode', 'scheduled')
->set('scheduledAt', '2026-06-08T09:00')
->set('useEmbargo', true)
->set('embargoAt', '2026-06-12T12:00')
->call('save')
->assertHasNoErrors();
$pr->refresh();
expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-08 09:00:00');
expect($pr->embargo_at?->toDateTimeString())->toBe('2026-06-12 12:00:00');
});
test('admin publishMode now clears scheduled_at on save', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
$admin = makeAdmin();
$this->actingAs($admin);
$pr = PressRelease::factory()->create([
'scheduled_at' => '2026-06-05 14:30:00',
'embargo_at' => '2026-06-10 08:00:00',
]);
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
->set('publishMode', 'now')
->set('useEmbargo', false)
->call('save')
->assertHasNoErrors();
$pr->refresh();
expect($pr->scheduled_at)->toBeNull();
expect($pr->embargo_at)->toBeNull();
});

View file

@ -0,0 +1,124 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
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);
});
function makeAdminForShow(): User
{
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
return $admin;
}
test('admin show rendert Rejection-Banner mit letzter Begründung', function () {
/** @var TestCase $this */
$admin = makeAdminForShow();
$this->actingAs($admin);
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Rejected->value,
]);
PressReleaseStatusLog::query()->create([
'press_release_id' => $pr->id,
'changed_by_user_id' => $admin->id,
'from_status' => PressReleaseStatus::Review->value,
'to_status' => PressReleaseStatus::Rejected->value,
'reason' => 'Werbliche Sprache, bitte überarbeiten.',
'source' => 'admin',
'created_at' => now(),
]);
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
->assertSee('Diese Pressemitteilung wurde abgelehnt')
->assertSee('Werbliche Sprache, bitte überarbeiten.');
});
test('admin show zeigt zugeordnete Pressekontakte', function () {
/** @var TestCase $this */
$admin = makeAdminForShow();
$this->actingAs($admin);
$company = Company::factory()->presseecho()->create();
$contact = Contact::factory()->for($company)->create([
'first_name' => 'Max',
'last_name' => 'Mustermann',
'responsibility' => 'Pressesprecher',
'email' => 'presse@example.test',
'portal' => $company->portal->value,
]);
$pr = PressRelease::factory()->create(['company_id' => $company->id]);
$pr->contacts()->sync([$contact->id]);
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
->assertSee('Zugeordnete Pressekontakte')
->assertSee('Max Mustermann')
->assertSee('Pressesprecher')
->assertSee('presse@example.test');
});
test('admin show zeigt Hinweis bei fehlenden Kontakten', function () {
/** @var TestCase $this */
$admin = makeAdminForShow();
$this->actingAs($admin);
$pr = PressRelease::factory()->create();
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
->assertSee('Dieser Pressemitteilung ist kein Pressekontakt zugeordnet.');
});
test('admin show zeigt Scheduling-Termin im Review-Workflow', function () {
/** @var TestCase $this */
$admin = makeAdminForShow();
$this->actingAs($admin);
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => '2026-06-15 10:00:00',
]);
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
->assertSee('Geplante Veröffentlichung')
->assertSee('15.06.2026 10:00');
});
test('admin show zeigt Embargo-Info im Published-Workflow', function () {
/** @var TestCase $this */
$admin = makeAdminForShow();
$this->actingAs($admin);
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Published->value,
'published_at' => '2026-06-01 10:00:00',
'embargo_at' => now()->addDays(10),
]);
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
->assertSee('Sperrfrist bis');
});
test('admin show zeigt Autor im Status-Verlauf-Grid', function () {
/** @var TestCase $this */
$admin = makeAdminForShow();
$this->actingAs($admin);
$author = User::factory()->create(['name' => 'Anna Autorin']);
$pr = PressRelease::factory()->create(['user_id' => $author->id]);
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
->assertSee('Anna Autorin');
});

View file

@ -7,6 +7,7 @@ use App\Models\Profile;
use App\Models\User;
use App\Models\UserFilterPreset;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Storage;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
@ -555,6 +556,61 @@ test('admin can open user details modal from users index and see company link st
->assertSet('contactLookup', '');
});
test('admin users index free text search matches names and email parts', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create();
$admin->assignRole('admin');
User::factory()->create([
'name' => 'Seabstian Einrock',
'email' => 'info@connectar.de',
])->assignRole('customer');
User::factory()->create([
'name' => 'Barbara Barr',
'email' => 'barbara@example.com',
])->assignRole('customer');
$this->actingAs($admin);
LivewireVolt::test('admin.users')
->set('search', 'Seabstian')
->assertSee('info@connectar.de')
->assertDontSee('barbara@example.com')
->set('search', 'Einrock')
->assertSee('info@connectar.de')
->assertDontSee('barbara@example.com')
->set('search', 'connectar.de')
->assertSee('Seabstian Einrock')
->assertDontSee('Barbara Barr')
->set('search', 'Seabstian Einrock info@connectar.de')
->assertSee('info@connectar.de')
->assertDontSee('barbara@example.com')
->set('search', 'Barr')
->assertSee('barbara@example.com')
->assertDontSee('info@connectar.de');
});
test('admin users index uses full count pagination', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create();
$admin->assignRole('admin');
User::factory()->count(51)->create();
$this->actingAs($admin);
LivewireVolt::test('admin.users')
->assertViewHas('users', fn ($users): bool => $users instanceof LengthAwarePaginator
&& $users->perPage() === 50
&& $users->total() === 52
&& $users->lastPage() === 2);
});
test('admin users index supports workflow filters and quality badges', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);

View file

@ -10,7 +10,8 @@ use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
beforeEach(function () {
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
@ -75,6 +76,31 @@ test('press release list and KPI counts render when data exists', function () {
->assertSee('Alle anzeigen');
});
test('company dashboard preview is limited to the ten newest companies', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
for ($i = 1; $i <= 12; $i++) {
Company::factory()->create([
'owner_user_id' => $customer->id,
'name' => sprintf('Dashboard Firma %02d', $i),
'created_at' => now()->subDays(13 - $i),
]);
}
$this->actingAs($customer);
LivewireVolt::test('customer.dashboard')
->assertSee('12 zugeordnet')
->assertSee('Dashboard Firma 12')
->assertSee('Dashboard Firma 03')
->assertDontSee('Dashboard Firma 02')
->assertDontSee('Dashboard Firma 01')
->assertSee('Die zehn neuesten Firmen werden hier als Vorschau angezeigt.')
->assertSee(route('me.press-kits.index'), false);
});
test('profile completeness hint with percentage appears for partial profiles', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);

View file

@ -53,6 +53,28 @@ test('customer company switcher links to selected company detail', function () {
->assertSee(route('me.press-kits.show', $company->id, absolute: false));
});
test('customer company switcher limits rendered company options', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
for ($i = 1; $i <= 55; $i++) {
Company::factory()->presseecho()->create([
'name' => sprintf('Switcher Firma %02d', $i),
'owner_user_id' => $customer->id,
'created_at' => now()->subDays(56 - $i),
]);
}
$this->actingAs($customer);
LivewireVolt::test('customer.company-switcher')
->assertViewHas('companies', fn ($companies) => $companies->count() === 50)
->assertSee('Switcher Firma 55')
->assertSee('Switcher Firma 06')
->assertDontSee('Switcher Firma 05')
->assertSee('Weitere Firmen über');
});
test('customer press release list is filtered by selected company context', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);

View file

@ -0,0 +1,105 @@
<?php
use App\Enums\Portal;
use App\Models\Company;
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);
});
function makeCustomerForCreate(): User
{
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
return $customer;
}
test('Create-Route ist für eingeloggte Customer erreichbar', function () {
/** @var TestCase $this */
$customer = makeCustomerForCreate();
$this->actingAs($customer)
->get('/admin/me/firmen/anlegen')
->assertOk()
->assertSeeText('Neue Firma anlegen')
->assertSeeText('Firmenname');
});
test('Create-Route ist für Gäste nicht zugänglich', function () {
/** @var TestCase $this */
$this->get('/admin/me/firmen/anlegen')
->assertRedirect();
});
test('save erstellt eine Firma und ordnet sie dem User als Owner zu', function () {
/** @var TestCase $this */
$customer = makeCustomerForCreate();
LivewireVolt::actingAs($customer)
->test('customer.press-kits.create')
->set('name', 'Neue Brauerei AG')
->set('portal', 'presseecho')
->set('type', 'company')
->set('email', 'kontakt@brauerei.de')
->call('save')
->assertHasNoErrors()
->assertRedirect();
/** @var Company $company */
$company = Company::query()->withoutGlobalScopes()->where('name', 'Neue Brauerei AG')->first();
expect($company)->not->toBeNull()
->and($company->owner_user_id)->toBe($customer->id)
->and($company->portal->value)->toBe('presseecho')
->and($company->email)->toBe('kontakt@brauerei.de')
->and($company->is_active)->toBeTrue();
expect($customer->companies()->where('companies.id', $company->id)->wherePivot('role', 'owner')->exists())
->toBeTrue();
});
test('save validiert Pflichtfelder Name und Portal', function () {
/** @var TestCase $this */
$customer = makeCustomerForCreate();
LivewireVolt::actingAs($customer)
->test('customer.press-kits.create')
->set('name', '')
->set('portal', '')
->call('save')
->assertHasErrors(['name' => 'required', 'portal' => 'required']);
});
test('save lehnt unbekannte Portal-Werte ab', function () {
/** @var TestCase $this */
$customer = makeCustomerForCreate();
LivewireVolt::actingAs($customer)
->test('customer.press-kits.create')
->set('name', 'Test GmbH')
->set('portal', 'invalid')
->call('save')
->assertHasErrors(['portal']);
});
test('save akzeptiert Portal Both', function () {
/** @var TestCase $this */
$customer = makeCustomerForCreate();
LivewireVolt::actingAs($customer)
->test('customer.press-kits.create')
->set('name', 'Beide Portale GmbH')
->set('portal', 'both')
->set('type', 'company')
->call('save')
->assertHasNoErrors();
expect(Company::query()->withoutGlobalScopes()->where('name', 'Beide Portale GmbH')->value('portal'))
->toBe(Portal::Both);
});

View file

@ -0,0 +1,309 @@
<?php
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Schema;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
/**
* @return array{customer: User}
*/
function makeCustomerForPressKitsIndex(): array
{
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
return ['customer' => $customer];
}
test('Index zeigt Counter-Strip mit Firmen, aktiven, PMs und Kontakten', function () {
/** @var TestCase $this */
['customer' => $customer] = makeCustomerForPressKitsIndex();
$activeOne = Company::factory()->presseecho()->create();
$customer->companies()->attach($activeOne->id, ['role' => 'owner']);
Contact::factory()->count(2)->for($activeOne)->create(['portal' => $activeOne->portal->value]);
PressRelease::factory()->count(3)->for($activeOne)->create([
'user_id' => $customer->id,
'portal' => $activeOne->portal->value,
'status' => 'draft',
]);
$activeTwo = Company::factory()->businessportal24()->create();
$customer->companies()->attach($activeTwo->id, ['role' => 'responsible']);
Contact::factory()->for($activeTwo)->create(['portal' => $activeTwo->portal->value]);
$inactive = Company::factory()->presseecho()->inactive()->create();
$customer->companies()->attach($inactive->id, ['role' => 'member']);
$this->actingAs($customer)
->get('/admin/me/firmen')
->assertOk()
->assertSeeInOrder(['<b>3</b>', 'Firmen'], false)
->assertSeeInOrder(['<b>2</b>', 'aktiv'], false)
->assertSeeInOrder(['<b>3</b>', 'Pressemitteilungen gesamt'], false)
->assertSeeInOrder(['<b>3</b>', 'Pressekontakte hinterlegt'], false);
});
test('Saved-View Aktiv filtert nur is_active = true Firmen', function () {
/** @var TestCase $this */
['customer' => $customer] = makeCustomerForPressKitsIndex();
$active = Company::factory()->presseecho()->create(['name' => 'AlphaAktiv GmbH']);
$customer->companies()->attach($active->id, ['role' => 'owner']);
$inactive = Company::factory()->presseecho()->inactive()->create(['name' => 'ZetaInaktiv GmbH']);
$customer->companies()->attach($inactive->id, ['role' => 'owner']);
LivewireVolt::actingAs($customer)
->test('customer.press-kits.index')
->call('setSavedView', 'active')
->assertSet('savedView', 'active')
->assertSee('AlphaAktiv GmbH')
->assertDontSee('ZetaInaktiv GmbH');
});
test('Saved-View Inaktiv filtert nur is_active = false Firmen', function () {
/** @var TestCase $this */
['customer' => $customer] = makeCustomerForPressKitsIndex();
$active = Company::factory()->presseecho()->create(['name' => 'AlphaAktiv GmbH']);
$customer->companies()->attach($active->id, ['role' => 'owner']);
$inactive = Company::factory()->presseecho()->inactive()->create(['name' => 'ZetaInaktiv GmbH']);
$customer->companies()->attach($inactive->id, ['role' => 'owner']);
LivewireVolt::actingAs($customer)
->test('customer.press-kits.index')
->call('setSavedView', 'inactive')
->assertSee('ZetaInaktiv GmbH')
->assertDontSee('AlphaAktiv GmbH');
});
test('Saved-View Geteilt zeigt nur Firmen, bei denen User nicht Owner ist', function () {
/** @var TestCase $this */
['customer' => $customer] = makeCustomerForPressKitsIndex();
$owner = User::factory()->create();
$ownCompany = Company::factory()->presseecho()->create([
'name' => 'EigeneFirma GmbH',
'owner_user_id' => $customer->id,
]);
$sharedCompany = Company::factory()->presseecho()->create([
'name' => 'GeteilteFirma GmbH',
'owner_user_id' => $owner->id,
]);
$customer->companies()->attach($sharedCompany->id, ['role' => 'responsible']);
LivewireVolt::actingAs($customer)
->test('customer.press-kits.index')
->call('setSavedView', 'shared')
->assertSee('GeteilteFirma GmbH')
->assertDontSee('EigeneFirma GmbH');
});
test('Portal-Filter zeigt nur Firmen des gewählten Portals (oder both)', function () {
/** @var TestCase $this */
['customer' => $customer] = makeCustomerForPressKitsIndex();
$pe = Company::factory()->presseecho()->create(['name' => 'PresseechoCo GmbH']);
$bp = Company::factory()->businessportal24()->create(['name' => 'BusinessportalCo GmbH']);
$customer->companies()->attach([
$pe->id => ['role' => 'owner'],
$bp->id => ['role' => 'owner'],
]);
LivewireVolt::actingAs($customer)
->test('customer.press-kits.index')
->call('setPortalFilter', 'presseecho')
->assertSee('PresseechoCo GmbH')
->assertDontSee('BusinessportalCo GmbH');
});
test('Rollen-Filter Owner zeigt nur Firmen, in denen User Owner ist', function () {
/** @var TestCase $this */
['customer' => $customer] = makeCustomerForPressKitsIndex();
$otherOwner = User::factory()->create();
$owned = Company::factory()->presseecho()->create([
'name' => 'EigeneFirma GmbH',
'owner_user_id' => $customer->id,
]);
$memberCompany = Company::factory()->presseecho()->create([
'name' => 'MitgliedFirma GmbH',
'owner_user_id' => $otherOwner->id,
]);
$customer->companies()->attach($memberCompany->id, ['role' => 'member']);
LivewireVolt::actingAs($customer)
->test('customer.press-kits.index')
->call('setRoleFilter', 'owner')
->assertSee('EigeneFirma GmbH')
->assertDontSee('MitgliedFirma GmbH');
});
test('Suche filtert auf Firmennamen', function () {
/** @var TestCase $this */
['customer' => $customer] = makeCustomerForPressKitsIndex();
$alpha = Company::factory()->presseecho()->create(['name' => 'Alpha Brauerei AG']);
$beta = Company::factory()->presseecho()->create(['name' => 'Beta Verlag GmbH']);
$customer->companies()->attach([
$alpha->id => ['role' => 'owner'],
$beta->id => ['role' => 'owner'],
]);
LivewireVolt::actingAs($customer)
->test('customer.press-kits.index')
->set('search', 'Brauerei')
->assertSee('Alpha Brauerei AG')
->assertDontSee('Beta Verlag GmbH');
});
test('View-Mode kann auf list umgeschaltet werden', function () {
/** @var TestCase $this */
['customer' => $customer] = makeCustomerForPressKitsIndex();
$company = Company::factory()->presseecho()->create(['name' => 'FirmaA GmbH']);
$customer->companies()->attach($company->id, ['role' => 'owner']);
LivewireVolt::actingAs($customer)
->test('customer.press-kits.index')
->call('setViewMode', 'list')
->assertSet('viewMode', 'list')
->assertSee('FirmaA GmbH')
->assertSee('cursor-pointer', false)
->assertSee('firm-list-actions', false);
});
test('Empty-State noch-keine-Firma wird ohne Filter und ohne Firmen gezeigt', function () {
/** @var TestCase $this */
['customer' => $customer] = makeCustomerForPressKitsIndex();
$this->actingAs($customer)
->get('/admin/me/firmen')
->assertOk()
->assertSeeText('Noch keine Firma angelegt');
});
test('Empty-State Filter-ohne-Treffer wird bei aktiven Filtern ohne Match gezeigt', function () {
/** @var TestCase $this */
['customer' => $customer] = makeCustomerForPressKitsIndex();
$company = Company::factory()->presseecho()->create(['name' => 'Alpha GmbH']);
$customer->companies()->attach($company->id, ['role' => 'owner']);
LivewireVolt::actingAs($customer)
->test('customer.press-kits.index')
->set('search', 'Zeta')
->assertSee('Keine Firmen mit diesen Filtern');
});
test('Filter zurücksetzen leert alle Filter und Suche', function () {
/** @var TestCase $this */
['customer' => $customer] = makeCustomerForPressKitsIndex();
$company = Company::factory()->presseecho()->create(['name' => 'Alpha GmbH']);
$customer->companies()->attach($company->id, ['role' => 'owner']);
LivewireVolt::actingAs($customer)
->test('customer.press-kits.index')
->set('search', 'Zeta')
->call('setSavedView', 'inactive')
->call('setPortalFilter', 'presseecho')
->call('setRoleFilter', 'member')
->call('resetFilters')
->assertSet('search', '')
->assertSet('savedView', 'all')
->assertSet('portalFilter', '')
->assertSet('roleFilter', 'all')
->assertSee('Alpha GmbH');
});
test('Add-Tile wird nur auf der letzten Seite des Card-Grids gerendert', function () {
/** @var TestCase $this */
['customer' => $customer] = makeCustomerForPressKitsIndex();
$company = Company::factory()->presseecho()->create(['name' => 'Alpha GmbH']);
$customer->companies()->attach($company->id, ['role' => 'owner']);
LivewireVolt::actingAs($customer)
->test('customer.press-kits.index')
->assertSee('Neue Firma anlegen');
});
test('Firmenübersicht paginiert mit 50 Firmen pro Seite', function () {
/** @var TestCase $this */
['customer' => $customer] = makeCustomerForPressKitsIndex();
for ($i = 1; $i <= 51; $i++) {
Company::factory()->presseecho()->create([
'name' => sprintf('Paginiert Firma %02d', $i),
'owner_user_id' => $customer->id,
]);
}
LivewireVolt::actingAs($customer)
->test('customer.press-kits.index')
->assertViewHas('pressKits', fn ($pressKits) => $pressKits->perPage() === 50 && $pressKits->count() === 50)
->assertSee('portal-pagination', false)
->assertSee('aria-current="page"', false)
->assertSee('cursor-pointer', false)
->assertSee('Paginiert Firma 01')
->assertSee('Paginiert Firma 50')
->assertDontSee('Paginiert Firma 51');
});
test('Karte zeigt Status Aktiv, Portal-Pills und KPIs', function () {
/** @var TestCase $this */
['customer' => $customer] = makeCustomerForPressKitsIndex();
$company = Company::factory()->presseecho()->create([
'name' => 'Brauerei AG',
'is_active' => true,
]);
$customer->companies()->attach($company->id, ['role' => 'owner']);
Contact::factory()->count(2)->for($company)->create(['portal' => $company->portal->value]);
PressRelease::factory()->count(4)->for($company)->create([
'user_id' => $customer->id,
'portal' => $company->portal->value,
'status' => 'draft',
]);
$this->actingAs($customer)
->get('/admin/me/firmen')
->assertOk()
->assertSeeText('Aktiv')
->assertSeeText('Brauerei AG')
->assertSee('presseecho')
->assertSeeText('PMs');
});
test('Rollen-Legende wird unterhalb der Liste angezeigt', function () {
/** @var TestCase $this */
['customer' => $customer] = makeCustomerForPressKitsIndex();
$this->actingAs($customer)
->get('/admin/me/firmen')
->assertOk()
->assertSeeText('Rollen pro Firma');
});
test('Performance-Indexe für die Firmenübersicht sind vorhanden', function () {
$companyIndexes = collect(Schema::getIndexes('companies'))->pluck('name');
$pressReleaseIndexes = collect(Schema::getIndexes('press_releases'))->pluck('name');
expect($companyIndexes)
->toContain('companies_owner_name_id_idx')
->toContain('companies_owner_active_name_id_idx')
->and($pressReleaseIndexes)
->toContain('press_releases_company_published_idx')
->toContain('press_releases_user_created_id_idx')
->toContain('press_releases_user_status_created_idx');
});

View file

@ -97,7 +97,7 @@ test('save with all required fields persists the press release and syncs contact
expect($pr->contacts->first()->id)->toBe($contact->id);
});
test('save without a contact id fails validation', function () {
test('save without a contact id succeeds and leaves contacts empty', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
@ -113,7 +113,28 @@ test('save without a contact id fails validation', function () {
->set('text', str_repeat('x', 60))
->set('categoryId', $category->id)
->call('save', 'draft')
->assertHasErrors(['contactId']);
->assertHasNoErrors(['contactId']);
$pr = PressRelease::query()->where('title', 'Titel mit genug Zeichen')->firstOrFail();
expect($pr->contacts()->count())->toBe(0);
});
test('presubmit check for missing contact is warning, not error', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$company = Company::factory()->presseecho()->create();
$customer->companies()->attach($company->id, ['role' => 'owner']);
$this->actingAs($customer);
$component = LivewireVolt::test('customer.press-releases.create')
->set('contactId', null);
$checks = collect($component->instance()->presubmitChecks);
$contactCheck = $checks->firstWhere('key', 'contact');
expect($contactCheck['status'])->toBe('warn');
});
test('boilerplate override is null when toggle is off even if text is filled', function () {

View file

@ -0,0 +1,190 @@
<?php
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
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);
});
function makeCustomerWithPressRelease(array $prAttributes = []): array
{
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$company = Company::factory()->presseecho()->create();
$customer->companies()->attach($company->id, ['role' => 'owner']);
$contact = Contact::factory()->for($company)->create([
'portal' => $company->portal->value,
]);
$category = Category::factory()->create();
$pr = PressRelease::factory()->create(array_merge([
'user_id' => $customer->id,
'company_id' => $company->id,
'category_id' => $category->id,
'portal' => $company->portal->value,
'status' => 'draft',
], $prAttributes));
$pr->contacts()->sync([$contact->id]);
return compact('customer', 'company', 'contact', 'category', 'pr');
}
test('mount loads all Phase 7 fields and pivot contact', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr, 'contact' => $contact] = makeCustomerWithPressRelease([
'subtitle' => 'Untertitel der PM',
'boilerplate_override' => 'Spezielle Boilerplate.',
'keywords' => 'Test, Brauerei',
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
->assertSet('subtitle', 'Untertitel der PM')
->assertSet('boilerplateOverride', 'Spezielle Boilerplate.')
->assertSet('useBoilerplateOverride', true)
->assertSet('keywords', 'Test, Brauerei')
->assertSet('contactId', $contact->id)
->assertSet('currentStatus', 'draft');
});
test('mount falls back to first company contact when no pivot exists', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr, 'contact' => $contact] = makeCustomerWithPressRelease();
$pr->contacts()->detach();
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
->assertSet('contactId', $contact->id);
});
test('save persists all new Phase 7 fields and syncs contact', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease();
$newContact = Contact::factory()->for($pr->company)->create([
'portal' => $pr->company->portal->value,
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
->set('title', 'Aktualisierter Titel mit genug Zeichen')
->set('subtitle', 'Neue Subline')
->set('text', str_repeat('Aktualisierter Fließtext mit etwas Inhalt. ', 5))
->set('keywords', 'Neu, Frisch')
->set('contactId', $newContact->id)
->set('useBoilerplateOverride', true)
->set('boilerplateOverride', 'Override-Text für diese PM.')
->call('save')
->assertHasNoErrors();
$pr->refresh();
expect($pr->title)->toBe('Aktualisierter Titel mit genug Zeichen');
expect($pr->subtitle)->toBe('Neue Subline');
expect($pr->boilerplate_override)->toBe('Override-Text für diese PM.');
expect($pr->keywords)->toBe('Neu, Frisch');
expect($pr->contacts->pluck('id')->all())->toBe([$newContact->id]);
});
test('save without contact id succeeds and detaches existing contact', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease();
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
->set('contactId', null)
->call('save')
->assertHasNoErrors(['contactId']);
expect($pr->fresh()->contacts()->count())->toBe(0);
});
test('save nulls boilerplate_override when toggle is turned off', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease([
'boilerplate_override' => 'Alter Override-Text',
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
->assertSet('useBoilerplateOverride', true)
->set('useBoilerplateOverride', false)
->call('save')
->assertHasNoErrors();
$pr->refresh();
expect($pr->boilerplate_override)->toBeNull();
});
test('changing the company resets the contact to the new company default', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease();
$secondCompany = Company::factory()->presseecho()->create();
$customer->companies()->attach($secondCompany->id, ['role' => 'owner']);
$secondContact = Contact::factory()->for($secondCompany)->create([
'portal' => $secondCompany->portal->value,
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
->set('companyId', $secondCompany->id)
->assertSet('contactId', $secondContact->id);
});
test('rejected press releases can be edited and re-submitted', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease([
'status' => 'rejected',
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
->assertSet('currentStatus', 'rejected');
});
test('foreign press release returns 403 on mount', function () {
/** @var TestCase $this */
['pr' => $pr] = makeCustomerWithPressRelease();
$stranger = User::factory()->create(['is_active' => true]);
$stranger->assignRole('customer');
$this->actingAs($stranger);
$this->get(route('me.press-releases.edit', $pr->id))
->assertNotFound();
});
test('addTag and removeTag work in edit form', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease([
'keywords' => 'Eins',
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
->call('addTag', 'Zwei')
->assertSet('keywords', 'Eins, Zwei')
->call('removeTag', 'Eins')
->assertSet('keywords', 'Zwei');
});

View file

@ -0,0 +1,135 @@
<?php
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Carbon;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
function makeSchedulingCustomer(): array
{
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$company = Company::factory()->presseecho()->create();
$customer->companies()->attach($company->id, ['role' => 'owner']);
$contact = Contact::factory()->for($company)->create([
'portal' => $company->portal->value,
]);
$category = Category::factory()->create();
return compact('customer', 'company', 'contact', 'category');
}
test('create form persistiert scheduled_at und embargo_at', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
['customer' => $customer, 'category' => $category] = makeSchedulingCustomer();
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.create')
->set('title', 'Phase 7F Scheduling Demo')
->set('text', str_repeat('Inhalt eines Tests. ', 5))
->set('categoryId', $category->id)
->set('publishMode', 'scheduled')
->set('scheduledAt', '2026-06-05T14:30')
->set('useEmbargo', true)
->set('embargoAt', '2026-06-10T08:00')
->call('save')
->assertHasNoErrors();
$pr = PressRelease::query()->latest('id')->firstOrFail();
expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-05 14:30:00');
expect($pr->embargo_at?->toDateTimeString())->toBe('2026-06-10 08:00:00');
});
test('create form lehnt scheduled_at in der Vergangenheit ab', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
['customer' => $customer, 'category' => $category] = makeSchedulingCustomer();
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.create')
->set('title', 'Vergangene Veröffentlichung')
->set('text', str_repeat('Inhalt eines Tests. ', 5))
->set('categoryId', $category->id)
->set('publishMode', 'scheduled')
->set('scheduledAt', '2026-05-30T10:00')
->call('save')
->assertHasErrors(['scheduledAt']);
});
test('create form lehnt embargo_at in der Vergangenheit ab', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
['customer' => $customer, 'category' => $category] = makeSchedulingCustomer();
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.create')
->set('title', 'Vergangenes Embargo')
->set('text', str_repeat('Inhalt eines Tests. ', 5))
->set('categoryId', $category->id)
->set('useEmbargo', true)
->set('embargoAt', '2026-05-30T10:00')
->call('save')
->assertHasErrors(['embargoAt']);
});
test('publishMode now setzt scheduled_at auf null beim Save', function () {
/** @var TestCase $this */
['customer' => $customer, 'category' => $category] = makeSchedulingCustomer();
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.create')
->set('title', 'Sofort veröffentlichen')
->set('text', str_repeat('Inhalt eines Tests. ', 5))
->set('categoryId', $category->id)
->set('publishMode', 'now')
->set('scheduledAt', '2026-12-31T12:00')
->call('save')
->assertHasNoErrors();
$pr = PressRelease::query()->latest('id')->firstOrFail();
expect($pr->scheduled_at)->toBeNull();
});
test('edit form hydriert scheduled_at und embargo_at', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
['customer' => $customer, 'company' => $company, 'contact' => $contact, 'category' => $category] = makeSchedulingCustomer();
$pr = PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'category_id' => $category->id,
'portal' => $company->portal->value,
'status' => 'draft',
'scheduled_at' => '2026-06-05 14:30:00',
'embargo_at' => '2026-06-10 08:00:00',
]);
$pr->contacts()->sync([$contact->id]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
->assertSet('publishMode', 'scheduled')
->assertSet('scheduledAt', '2026-06-05T14:30')
->assertSet('useEmbargo', true)
->assertSet('embargoAt', '2026-06-10T08:00');
});

View file

@ -10,9 +10,7 @@ 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;
@ -61,82 +59,43 @@ test('customer can update own profile fields', function () {
expect($customer->billingAddress?->country_code)->toBe('DE');
});
test('customer can update an owned company and upload a logo with variants', function () {
test('customer profile keeps company management out of the profile page', 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',
'name' => 'Nicht im Profil geladene Firma',
]);
$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();
->assertSee('Rechnungsadresse')
->assertSee('Profileinstellungen')
->assertSee('Firmen verwalten')
->assertDontSee('Zugeordnete Firmen')
->assertDontSee('Nicht im Profil geladene Firma');
});
test('customer cannot edit a company they do not own or co-manage', function () {
test('customer can save profile settings without a billing address', 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',
$customer = User::factory()->create([
'is_active' => true,
'name' => 'Ohne Rechnung',
]);
$this->actingAs($customer);
LivewireVolt::test('customer.profile')
->set('editableCompanyId', $company->id)
->set('companyName', 'Owner Company Updated')
->call('saveCompany')
->set('name', 'Nur Profil')
->set('firstName', 'Nur')
->set('lastName', 'Profil')
->call('saveProfile')
->assertHasNoErrors();
expect($company->refresh()->name)->toBe('Owner Company Updated');
expect($customer->refresh()->name)->toBe('Nur Profil');
expect($customer->billingAddress)->toBeNull();
});
test('customer security page renders password and email forms', function () {
@ -221,6 +180,7 @@ test('customer press releases derive portal from selected company', function ()
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create();
$customer->companies()->attach($company->id, ['role' => 'owner']);
$contact = Contact::factory()->for($company)->create(['portal' => $company->portal->value]);
$this->actingAs($customer);
@ -228,6 +188,7 @@ test('customer press releases derive portal from selected company', function ()
->set('companyId', $company->id)
->set('portal', Portal::Businessportal24->value)
->set('categoryId', Category::factory()->create()->id)
->set('contactId', $contact->id)
->set('title', 'Neue Meldung fuer Presseecho')
->set('text', str_repeat('Dies ist ein ausreichend langer Testtext. ', 3))
->call('save', 'draft')

View file

@ -0,0 +1,44 @@
<?php
use Illuminate\Support\Facades\File;
test('views load fonts from local assets only', function () {
$externalFontHosts = [
'fonts.bunny.net',
'fonts.googleapis.com',
'fonts.gstatic.com',
];
foreach (File::allFiles(resource_path('views')) as $view) {
$contents = File::get($view->getRealPath());
foreach ($externalFontHosts as $host) {
$this->assertStringNotContainsString(
$host,
$contents,
$view->getRelativePathname().' references '.$host,
);
}
}
$localFontLinks = view('partials.local-fonts')->render();
expect($localFontLinks)
->toContain('fonts/inter-tight/font.css')
->toContain('fonts/source-serif-4/font.css')
->toContain('fonts/jetbrains-mono/font.css');
});
test('local font stylesheets reference font files relative to their directories', function () {
foreach (File::allFiles(public_path('fonts')) as $file) {
if ($file->getExtension() !== 'css') {
continue;
}
$this->assertStringNotContainsString(
'../fonts/',
File::get($file->getRealPath()),
$file->getRelativePathname().' still points outside its font directory',
);
}
});

View file

@ -58,6 +58,24 @@ test('customer can access me dashboard but not admin dashboard', function () {
$this->get(route('dashboard'))->assertForbidden();
});
test('customer bookings page shows credit packages and add ons from pricing concept', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$this->actingAs($customer)
->get(route('me.bookings.index'))
->assertSuccessful()
->assertSee('Credit-Pakete')
->assertSee('Standard')
->assertSee('50')
->assertSee('45 €')
->assertSee('Pressetext-Optimierung')
->assertSee('Top-Slot Startseite')
->assertSee('Score 80+')
->assertSee('Noch keine aktiven Buchungen');
});
test('admin can access both panel dashboards', function () {
/** @var TestCase $this */
$admin = User::factory()->create(['is_active' => true]);

View file

@ -0,0 +1,219 @@
<?php
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseAttachment;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
function publicDisk(): FilesystemAdapter
{
/** @var FilesystemAdapter $disk */
$disk = Storage::disk('public');
return $disk;
}
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Storage::fake('public');
});
function makeDraftWithOwner(string $status = 'draft'): array
{
$owner = User::factory()->create(['is_active' => true]);
$owner->assignRole('customer');
$company = Company::factory()->presseecho()->create();
$owner->companies()->attach($company->id, ['role' => 'owner']);
$pr = PressRelease::factory()->create([
'user_id' => $owner->id,
'company_id' => $company->id,
'category_id' => Category::factory()->create()->id,
'portal' => $company->portal->value,
'status' => $status,
]);
return compact('owner', 'company', 'pr');
}
test('owner can upload a PDF attachment', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner();
$this->actingAs($owner);
$file = UploadedFile::fake()->create('factsheet.pdf', 200, 'application/pdf');
LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
->set('newFile', $file)
->set('newTitle', 'Factsheet Q1')
->set('newDescription', 'Wirtschaftliche Kennzahlen')
->call('upload')
->assertHasNoErrors();
$att = $pr->attachments()->first();
expect($att)->not->toBeNull();
expect($att->title)->toBe('Factsheet Q1');
expect($att->description)->toBe('Wirtschaftliche Kennzahlen');
expect($att->original_name)->toBe('factsheet.pdf');
expect($att->mime)->toBe('application/pdf');
expect($att->sort_order)->toBe(1);
expect($att->disk)->toBe('public');
publicDisk()->assertExists($att->path);
});
test('upload rejects executable / disallowed file types', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner();
$this->actingAs($owner);
$bad = UploadedFile::fake()->create('shell.exe', 50, 'application/octet-stream');
LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
->set('newFile', $bad)
->call('upload')
->assertHasErrors(['newFile']);
expect($pr->attachments()->count())->toBe(0);
});
test('upload rejects oversized files', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner();
$this->actingAs($owner);
// 26 MB (limit ist 25 MB).
$tooBig = UploadedFile::fake()->create('big.pdf', 26 * 1024, 'application/pdf');
LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
->set('newFile', $tooBig)
->call('upload')
->assertHasErrors(['newFile']);
expect($pr->attachments()->count())->toBe(0);
});
test('sort_order increments and moveUp / moveDown swap order', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner();
$a = PressReleaseAttachment::factory()->for($pr)->create(['sort_order' => 1, 'title' => 'A']);
$b = PressReleaseAttachment::factory()->for($pr)->create(['sort_order' => 2, 'title' => 'B']);
$c = PressReleaseAttachment::factory()->for($pr)->create(['sort_order' => 3, 'title' => 'C']);
$this->actingAs($owner);
LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
->call('moveUp', $c->id);
expect($b->fresh()->sort_order)->toBe(3);
expect($c->fresh()->sort_order)->toBe(2);
LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
->call('moveDown', $a->id);
// Nach moveDown sind a und c gleich (jeweils sort=2 nach swap). Es wird der Vorgänger getauscht — also a tauscht mit c (new neighbour).
// Wir prüfen pragmatisch: a steht nicht mehr auf 1.
expect($a->fresh()->sort_order)->not->toBe(1);
});
test('remove deletes the row and the file', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner();
$this->actingAs($owner);
$file = UploadedFile::fake()->create('toDelete.pdf', 50, 'application/pdf');
$component = LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
->set('newFile', $file)
->call('upload')
->assertHasNoErrors();
$att = $pr->attachments()->first();
publicDisk()->assertExists($att->path);
$component->call('remove', $att->id);
expect($pr->attachments()->withTrashed()->find($att->id)?->trashed())->toBeTrue();
publicDisk()->assertMissing($att->path);
});
test('startEdit + updateAttachment update title and description', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner();
$att = PressReleaseAttachment::factory()->for($pr)->create([
'title' => 'Alt',
'description' => 'Alte Beschreibung',
]);
$this->actingAs($owner);
LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
->call('startEdit', $att->id)
->assertSet('editingId', $att->id)
->assertSet('editTitle', 'Alt')
->set('editTitle', 'Neuer Titel')
->set('editDescription', 'Frische Beschreibung')
->call('updateAttachment')
->assertHasNoErrors()
->assertSet('editingId', null);
$att->refresh();
expect($att->title)->toBe('Neuer Titel');
expect($att->description)->toBe('Frische Beschreibung');
});
test('foreign customer cannot upload attachments', function () {
/** @var TestCase $this */
['pr' => $pr] = makeDraftWithOwner();
$stranger = User::factory()->create(['is_active' => true]);
$stranger->assignRole('customer');
$this->actingAs($stranger);
$file = UploadedFile::fake()->create('any.pdf', 30, 'application/pdf');
try {
LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
->set('newFile', $file)
->call('upload');
} catch (AuthorizationException) {
// Erwartet: Policy verhindert den Upload.
}
expect($pr->attachments()->count())->toBe(0);
});
test('attachments cannot be uploaded when PR is published', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner('published');
$this->actingAs($owner);
$file = UploadedFile::fake()->create('any.pdf', 30, 'application/pdf');
try {
LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
->set('newFile', $file)
->call('upload');
} catch (AuthorizationException) {
// Erwartet: Customer darf an published PR nichts ändern.
}
expect($pr->attachments()->count())->toBe(0);
});

View file

@ -0,0 +1,167 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
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);
});
/**
* @return array{customer: User, company: Company, contact: Contact, category: Category}
*/
function makeCustomerForContactWarning(): array
{
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$company = Company::factory()->presseecho()->create();
$customer->companies()->attach($company->id, ['role' => 'owner']);
$contact = Contact::factory()->for($company)->create([
'portal' => $company->portal->value,
'phone' => '+49 30 1234567',
]);
$category = Category::factory()->create();
return compact('customer', 'company', 'contact', 'category');
}
function makeAdminForContactWarning(): User
{
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
return $admin;
}
test('customer create zeigt Warn-Box, wenn kein Pressekontakt gewählt ist', function () {
/** @var TestCase $this */
['customer' => $customer, 'company' => $company] = makeCustomerForContactWarning();
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.create')
->set('companyId', $company->id)
->set('contactId', null)
->assertSee('Noch kein Pressekontakt ausgewählt');
});
test('customer create zeigt Warn-Box NICHT, wenn ein Kontakt gewählt ist', function () {
/** @var TestCase $this */
['customer' => $customer, 'company' => $company, 'contact' => $contact] = makeCustomerForContactWarning();
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.create')
->set('companyId', $company->id)
->set('contactId', $contact->id)
->assertDontSee('Noch kein Pressekontakt ausgewählt');
});
test('customer edit zeigt Warn-Box, wenn kein Pressekontakt gewählt ist', function () {
/** @var TestCase $this */
['customer' => $customer, 'company' => $company, 'category' => $category] = makeCustomerForContactWarning();
$this->actingAs($customer);
$pr = PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'category_id' => $category->id,
'portal' => $company->portal->value,
'status' => PressReleaseStatus::Draft->value,
]);
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
->set('contactId', null)
->assertSee('Noch kein Pressekontakt ausgewählt');
});
test('customer edit zeigt Warn-Box NICHT, wenn ein Kontakt gewählt ist', function () {
/** @var TestCase $this */
['customer' => $customer, 'company' => $company, 'contact' => $contact, 'category' => $category] = makeCustomerForContactWarning();
$this->actingAs($customer);
$pr = PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'category_id' => $category->id,
'portal' => $company->portal->value,
'status' => PressReleaseStatus::Draft->value,
]);
$pr->contacts()->sync([$contact->id]);
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
->assertDontSee('Noch kein Pressekontakt ausgewählt');
});
test('admin create zeigt Warn-Box, wenn kein Pressekontakt gewählt ist', function () {
/** @var TestCase $this */
$admin = makeAdminForContactWarning();
$this->actingAs($admin);
$company = Company::factory()->presseecho()->create();
Contact::factory()->for($company)->create([
'portal' => $company->portal->value,
'phone' => '+49 30 7654321',
]);
LivewireVolt::test('admin.press-releases.create')
->set('companyId', $company->id)
->set('contactId', null)
->assertSee('Noch kein Pressekontakt ausgewählt');
});
test('admin edit zeigt Warn-Box, wenn kein Pressekontakt gewählt ist', function () {
/** @var TestCase $this */
$admin = makeAdminForContactWarning();
$this->actingAs($admin);
$company = Company::factory()->presseecho()->create();
Contact::factory()->for($company)->create([
'portal' => $company->portal->value,
'phone' => '+49 30 7654321',
]);
$category = Category::factory()->create();
$pr = PressRelease::factory()->create([
'company_id' => $company->id,
'category_id' => $category->id,
'portal' => $company->portal->value,
'status' => PressReleaseStatus::Draft->value,
]);
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
->set('contactId', null)
->assertSee('Noch kein Pressekontakt ausgewählt');
});
test('admin edit zeigt Warn-Box NICHT, wenn ein Kontakt gewählt ist', function () {
/** @var TestCase $this */
$admin = makeAdminForContactWarning();
$this->actingAs($admin);
$company = Company::factory()->presseecho()->create();
$contact = Contact::factory()->for($company)->create([
'portal' => $company->portal->value,
'phone' => '+49 30 7654321',
]);
$category = Category::factory()->create();
$pr = PressRelease::factory()->create([
'company_id' => $company->id,
'category_id' => $category->id,
'portal' => $company->portal->value,
'status' => PressReleaseStatus::Draft->value,
]);
$pr->contacts()->sync([$contact->id]);
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
->assertDontSee('Noch kein Pressekontakt ausgewählt');
});

View file

@ -0,0 +1,154 @@
<?php
use App\Enums\PressReleaseStatus;
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;
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
function makeCustomerForIndexPhase8b(): User
{
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
return $customer;
}
function makeAdminForIndexPhase8b(): User
{
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
return $admin;
}
test('customer list zeigt Scheduling-Sub-Zeile bei review-PMs mit scheduled_at', function () {
/** @var TestCase $this */
$customer = makeCustomerForIndexPhase8b();
$this->actingAs($customer);
$company = Company::factory()->presseecho()->create();
$customer->companies()->attach($company->id, ['role' => 'owner']);
PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'portal' => $company->portal->value,
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => now()->addDays(3)->setTime(14, 30),
]);
LivewireVolt::test('customer.press-releases.index')
->assertSee('geplant');
});
test('customer list zeigt Embargo-Sub-Zeile bei PMs mit embargo_at in der Zukunft', function () {
/** @var TestCase $this */
$customer = makeCustomerForIndexPhase8b();
$this->actingAs($customer);
$company = Company::factory()->presseecho()->create();
$customer->companies()->attach($company->id, ['role' => 'owner']);
PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'portal' => $company->portal->value,
'status' => PressReleaseStatus::Published->value,
'embargo_at' => now()->addDays(7),
]);
LivewireVolt::test('customer.press-releases.index')
->assertSee('Embargo bis');
});
test('customer list zeigt KEINE Scheduling-Sub-Zeile wenn scheduled_at in der Vergangenheit', function () {
/** @var TestCase $this */
$customer = makeCustomerForIndexPhase8b();
$this->actingAs($customer);
$company = Company::factory()->presseecho()->create();
$customer->companies()->attach($company->id, ['role' => 'owner']);
PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'portal' => $company->portal->value,
'status' => PressReleaseStatus::Published->value,
'scheduled_at' => now()->subDay(),
]);
LivewireVolt::test('customer.press-releases.index')
->assertDontSee('geplant ·');
});
test('customer list zeigt KEINE Embargo-Sub-Zeile wenn embargo_at abgelaufen', function () {
/** @var TestCase $this */
$customer = makeCustomerForIndexPhase8b();
$this->actingAs($customer);
$company = Company::factory()->presseecho()->create();
$customer->companies()->attach($company->id, ['role' => 'owner']);
PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'portal' => $company->portal->value,
'status' => PressReleaseStatus::Published->value,
'embargo_at' => now()->subDay(),
]);
LivewireVolt::test('customer.press-releases.index')
->assertDontSee('Embargo bis');
});
test('admin list zeigt Scheduling-Sub-Zeile bei review-PMs mit scheduled_at', function () {
/** @var TestCase $this */
$admin = makeAdminForIndexPhase8b();
$this->actingAs($admin);
PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => now()->addDays(2)->setTime(10, 0),
]);
LivewireVolt::test('admin.press-releases.index')
->assertSee('geplant');
});
test('admin list zeigt Embargo-Sub-Zeile bei PMs mit embargo_at in der Zukunft', function () {
/** @var TestCase $this */
$admin = makeAdminForIndexPhase8b();
$this->actingAs($admin);
PressRelease::factory()->create([
'status' => PressReleaseStatus::Published->value,
'embargo_at' => now()->addDays(5),
]);
LivewireVolt::test('admin.press-releases.index')
->assertSee('Embargo bis');
});
test('admin list zeigt KEINE Sub-Zeilen wenn weder scheduled_at noch embargo_at', function () {
/** @var TestCase $this */
$admin = makeAdminForIndexPhase8b();
$this->actingAs($admin);
PressRelease::factory()->create([
'status' => PressReleaseStatus::Draft->value,
'scheduled_at' => null,
'embargo_at' => null,
]);
LivewireVolt::test('admin.press-releases.index')
->assertDontSee('geplant ·')
->assertDontSee('Embargo bis');
});

View file

@ -0,0 +1,193 @@
<?php
use App\Console\Commands\PublishScheduledPressReleases;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Services\PressRelease\PressReleaseService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Mail::fake();
});
test('publish ohne scheduled_at und ohne embargo_at setzt published_at auf jetzt', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => null,
'embargo_at' => null,
'published_at' => null,
]);
app(PressReleaseService::class)->publish($pr);
expect($pr->fresh()->published_at?->toDateTimeString())->toBe('2026-06-01 10:00:00');
});
test('publish mit scheduled_at in der Zukunft setzt published_at auf den Termin', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => '2026-06-05 14:30:00',
'embargo_at' => null,
'published_at' => null,
]);
app(PressReleaseService::class)->publish($pr);
expect($pr->fresh()->published_at?->toDateTimeString())->toBe('2026-06-05 14:30:00');
});
test('publish mit embargo_at in der Zukunft verschiebt published_at auf das Embargo', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => null,
'embargo_at' => '2026-06-10 08:00:00',
'published_at' => null,
]);
app(PressReleaseService::class)->publish($pr);
expect($pr->fresh()->published_at?->toDateTimeString())->toBe('2026-06-10 08:00:00');
});
test('publish mit scheduled_at und späterem embargo_at nimmt das Embargo', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => '2026-06-05 14:30:00',
'embargo_at' => '2026-06-10 08:00:00',
'published_at' => null,
]);
app(PressReleaseService::class)->publish($pr);
expect($pr->fresh()->published_at?->toDateTimeString())->toBe('2026-06-10 08:00:00');
});
test('publish übernimmt bereits gesetztes published_at und überschreibt nicht', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => '2026-06-05 14:30:00',
'embargo_at' => null,
'published_at' => '2025-12-01 00:00:00',
]);
app(PressReleaseService::class)->publish($pr);
expect($pr->fresh()->published_at?->toDateTimeString())->toBe('2025-12-01 00:00:00');
});
test('publish-Source landet als source im Status-Log', function () {
/** @var TestCase $this */
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'published_at' => null,
]);
app(PressReleaseService::class)->publish($pr, source: 'scheduler');
$log = PressReleaseStatusLog::query()->latest('id')->firstOrFail();
expect($log->source)->toBe('scheduler');
expect($log->to_status)->toBe(PressReleaseStatus::Published);
});
test('Command publisht fällige Review-PMs mit scheduled_at <= now', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
$due = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => '2026-06-01 11:55:00',
'published_at' => null,
]);
Artisan::call(PublishScheduledPressReleases::class);
$fresh = $due->fresh();
expect($fresh->status)->toBe(PressReleaseStatus::Published);
expect($fresh->published_at?->toDateTimeString())->toBe('2026-06-01 11:55:00');
});
test('Command ignoriert PMs mit scheduled_at in der Zukunft', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
$future = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => '2026-06-01 12:30:00',
'published_at' => null,
]);
Artisan::call(PublishScheduledPressReleases::class);
expect($future->fresh()->status)->toBe(PressReleaseStatus::Review);
});
test('Command ignoriert PMs ohne scheduled_at', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
$manual = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => null,
]);
Artisan::call(PublishScheduledPressReleases::class);
expect($manual->fresh()->status)->toBe(PressReleaseStatus::Review);
});
test('Command läuft mit dry-run ohne Statusänderung', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
$due = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => '2026-06-01 11:50:00',
'published_at' => null,
]);
Artisan::call(PublishScheduledPressReleases::class, ['--dry-run' => true]);
expect($due->fresh()->status)->toBe(PressReleaseStatus::Review);
});
test('Command publisht maximal --limit pro Lauf', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
PressRelease::factory()->count(3)->state([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => '2026-06-01 11:50:00',
'published_at' => null,
])->create();
Artisan::call(PublishScheduledPressReleases::class, ['--limit' => 2]);
$publishedCount = PressRelease::withoutGlobalScopes()
->where('status', PressReleaseStatus::Published->value)
->count();
expect($publishedCount)->toBe(2);
});

View file

@ -0,0 +1,168 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
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);
});
/**
* @return array{customer: User, company: Company, contact: Contact, category: Category, pr: PressRelease}
*/
function makeCustomerForShowPhase8a(array $prAttributes = []): array
{
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$company = Company::factory()->presseecho()->create();
$customer->companies()->attach($company->id, ['role' => 'owner']);
$contact = Contact::factory()->for($company)->create([
'portal' => $company->portal->value,
]);
$category = Category::factory()->create();
$pr = PressRelease::factory()->create(array_merge([
'user_id' => $customer->id,
'company_id' => $company->id,
'category_id' => $category->id,
'portal' => $company->portal->value,
'status' => 'draft',
], $prAttributes));
$pr->contacts()->sync([$contact->id]);
return compact('customer', 'company', 'contact', 'category', 'pr');
}
function makeAdminForShowPhase8a(): User
{
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
return $admin;
}
test('customer show zeigt den Untertitel direkt unter dem Titel', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a([
'title' => 'Brauerei eröffnet zweiten Standort',
'subtitle' => 'Ein Untertitel der Pressemitteilung',
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Brauerei eröffnet zweiten Standort')
->assertSee('Ein Untertitel der Pressemitteilung');
});
test('customer show zeigt geplante Veröffentlichung wenn gesetzt', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => '2026-07-01 09:30:00',
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Geplante Veröffentlichung')
->assertSee('01.07.2026 09:30');
});
test('customer show zeigt Sperrfrist wenn embargo_at gesetzt', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a([
'status' => PressReleaseStatus::Published->value,
'embargo_at' => '2026-08-15 12:00:00',
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Sperrfrist bis')
->assertSee('15.08.2026 12:00');
});
test('customer show zeigt Kein-Export-Hinweis wenn no_export aktiv', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a([
'no_export' => true,
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Kein Export aktiv');
});
test('customer show zeigt Boilerplate-Override als eigene Card', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a([
'boilerplate_override' => 'Über die Beispiel AG: Wir machen Bier seit 1850.',
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Eigener Abbinder (Boilerplate)')
->assertSee('Über die Beispiel AG: Wir machen Bier seit 1850.');
});
test('customer show zeigt Boilerplate-Override nicht wenn leer', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a([
'boilerplate_override' => null,
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertDontSee('Eigener Abbinder (Boilerplate)');
});
test('admin show zeigt den Untertitel direkt unter dem Titel', function () {
/** @var TestCase $this */
$admin = makeAdminForShowPhase8a();
$this->actingAs($admin);
$pr = PressRelease::factory()->create([
'title' => 'Großer Auftritt der Brauerei',
'subtitle' => 'Pressekonferenz am Freitag',
]);
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
->assertSee('Großer Auftritt der Brauerei')
->assertSee('Pressekonferenz am Freitag');
});
test('admin show zeigt Boilerplate-Override als eigene Card', function () {
/** @var TestCase $this */
$admin = makeAdminForShowPhase8a();
$this->actingAs($admin);
$pr = PressRelease::factory()->create([
'boilerplate_override' => 'Über das Beispiel-Unternehmen: Mehr Infos folgen.',
]);
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
->assertSee('Eigener Abbinder (Boilerplate)')
->assertSee('Über das Beispiel-Unternehmen: Mehr Infos folgen.');
});
test('admin show zeigt Boilerplate-Override nicht wenn leer', function () {
/** @var TestCase $this */
$admin = makeAdminForShowPhase8a();
$this->actingAs($admin);
$pr = PressRelease::factory()->create([
'boilerplate_override' => null,
]);
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
->assertDontSee('Eigener Abbinder (Boilerplate)');
});