- 83 Komponenten: Livewire\Volt\Component → Livewire\Component
- 73 Routen (admin, auth, customer): Volt::route() → Route::livewire()
- 88 Testdateien: Volt::test/actingAs → Livewire::test/actingAs
- VoltServiceProvider gelöscht; composer remove livewire/volt
- config/livewire.php auf v4-Schlüssel (component_layout, component_placeholder,
component_locations, make_command: SFC ohne ⚡). Der alte Schlüssel layout war
wirkungslos; alle gerouteten Komponenten setzen ihr Layout selbst.
- CLAUDE.md, AGENTS.md, boost.json, .cursorrules, Skill volt-development
- Roadmap: erledigt; Larastan erfasst SFCs weiterhin nicht (Blade-Dateien)
Suite 2.325 grün, einer übersprungen; Larastan und Pint ohne Meldung;
route:cache und view:cache laufen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
301 lines
12 KiB
PHP
301 lines
12 KiB
PHP
<?php
|
||
|
||
use App\Enums\AiImageDeclaration;
|
||
use App\Enums\AiTextDeclaration;
|
||
use App\Enums\ImageLicenseType;
|
||
use App\Enums\PressReleaseStatus;
|
||
use App\Models\Category;
|
||
use App\Models\Company;
|
||
use App\Models\PressRelease;
|
||
use App\Models\PressReleaseAiDeclaration;
|
||
use App\Models\User;
|
||
use App\Services\PressRelease\AiDeclarationRequiredException;
|
||
use App\Services\PressRelease\PressReleaseService;
|
||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||
use Illuminate\Http\UploadedFile;
|
||
use Illuminate\Support\Facades\Queue;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Livewire\Livewire;
|
||
use Tests\TestCase;
|
||
|
||
/**
|
||
* KI-Kennzeichnung (Art. 50 KI-VO, Decision-Update 08.07.2026):
|
||
* Pflicht-Selbstdeklaration im Submit-Flow, versionierter Audit-Trail,
|
||
* öffentliches Label in Einreicher-Stimme.
|
||
*/
|
||
beforeEach(function (): void {
|
||
/** @var TestCase $this */
|
||
$this->seed(RolesAndPermissionsSeeder::class);
|
||
Queue::fake();
|
||
});
|
||
|
||
function makeAiDeclarationCustomer(): array
|
||
{
|
||
$customer = User::factory()->create(['is_active' => true]);
|
||
$customer->assignRole('customer');
|
||
|
||
$company = Company::factory()->presseecho()->create();
|
||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||
|
||
return [$customer, $company];
|
||
}
|
||
|
||
test('the text declaration maps to the attributed public label per decision update', function () {
|
||
// Zwei Gruppen (Vereinfachung 10.07.2026): None/Assisted → kein Label,
|
||
// MostlyAi/FullyAi → gemeinsames „KI-generiert"-Label in Einreicher-Stimme.
|
||
expect(AiTextDeclaration::None->publicLabel())->toBeNull();
|
||
expect(AiTextDeclaration::Assisted->publicLabel())->toBeNull();
|
||
expect(AiTextDeclaration::MostlyAi->publicLabel())
|
||
->toBe('KI-generiert: Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung ganz oder überwiegend mit generativer KI erstellt.');
|
||
expect(AiTextDeclaration::FullyAi->publicLabel())
|
||
->toBe('KI-generiert: Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung ganz oder überwiegend mit generativer KI erstellt.');
|
||
});
|
||
|
||
test('every stored value maps onto one of the two form options', function () {
|
||
expect(AiTextDeclaration::None->formOption())->toBe(AiTextDeclaration::None);
|
||
expect(AiTextDeclaration::Assisted->formOption())->toBe(AiTextDeclaration::None);
|
||
expect(AiTextDeclaration::MostlyAi->formOption())->toBe(AiTextDeclaration::MostlyAi);
|
||
expect(AiTextDeclaration::FullyAi->formOption())->toBe(AiTextDeclaration::MostlyAi);
|
||
expect(AiTextDeclaration::formCases())->toBe([AiTextDeclaration::None, AiTextDeclaration::MostlyAi]);
|
||
});
|
||
|
||
test('the image declaration maps to the public image hint per decision update', function () {
|
||
// Kurzform als Badge direkt am Bild (User-Entscheidung 10.07.2026).
|
||
expect(AiImageDeclaration::Original->publicLabel())->toBeNull();
|
||
expect(AiImageDeclaration::AiGenerated->publicLabel())->toBe('KI-generiert');
|
||
expect(AiImageDeclaration::AiEdited->publicLabel())->toBe('KI-bearbeitet');
|
||
});
|
||
|
||
test('submit is blocked without a complete ai declaration', function () {
|
||
/** @var TestCase $this */
|
||
[$customer] = makeAiDeclarationCustomer();
|
||
|
||
$pr = PressRelease::factory()->aiUndeclared()->create([
|
||
'user_id' => $customer->id,
|
||
'status' => PressReleaseStatus::Draft->value,
|
||
]);
|
||
|
||
$this->actingAs($customer);
|
||
|
||
expect(fn () => app(PressReleaseService::class)->submitForReview($pr))
|
||
->toThrow(AiDeclarationRequiredException::class);
|
||
|
||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft);
|
||
expect(PressReleaseAiDeclaration::query()->count())->toBe(0);
|
||
});
|
||
|
||
test('submit is blocked while any image lacks its ai declaration', function () {
|
||
/** @var TestCase $this */
|
||
[$customer] = makeAiDeclarationCustomer();
|
||
|
||
$pr = PressRelease::factory()->create([
|
||
'user_id' => $customer->id,
|
||
'status' => PressReleaseStatus::Draft->value,
|
||
]);
|
||
|
||
$pr->images()->create([
|
||
'disk' => 'public',
|
||
'path' => 'press-releases/'.$pr->id.'/images/title.jpg',
|
||
'author' => 'Jane Doe',
|
||
'license_type' => ImageLicenseType::Own->value,
|
||
'rights_confirmed_at' => now(),
|
||
'is_preview' => true,
|
||
'sort_order' => 1,
|
||
'ai_declaration' => null,
|
||
]);
|
||
|
||
$this->actingAs($customer);
|
||
|
||
expect(fn () => app(PressReleaseService::class)->submitForReview($pr))
|
||
->toThrow(AiDeclarationRequiredException::class);
|
||
|
||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft);
|
||
});
|
||
|
||
test('each submit writes a versioned immutable audit entry with the image snapshot', function () {
|
||
/** @var TestCase $this */
|
||
[$customer, $company] = makeAiDeclarationCustomer();
|
||
|
||
$pr = PressRelease::factory()->create([
|
||
'user_id' => $customer->id,
|
||
'company_id' => $company->id,
|
||
'status' => PressReleaseStatus::Draft->value,
|
||
'ai_text_declaration' => AiTextDeclaration::MostlyAi->value,
|
||
'ai_declaration_confirmed_at' => now(),
|
||
]);
|
||
|
||
$image = $pr->images()->create([
|
||
'disk' => 'public',
|
||
'path' => 'press-releases/'.$pr->id.'/images/title.jpg',
|
||
'author' => 'Beispiel GmbH',
|
||
'license_type' => ImageLicenseType::AiGenerated->value,
|
||
'rights_confirmed_at' => now(),
|
||
'is_ai_generated' => true,
|
||
'ai_declaration' => AiImageDeclaration::AiGenerated->value,
|
||
'is_preview' => true,
|
||
'sort_order' => 1,
|
||
]);
|
||
|
||
$this->actingAs($customer);
|
||
|
||
app(PressReleaseService::class)->submitForReview($pr);
|
||
|
||
$entry = PressReleaseAiDeclaration::query()->sole();
|
||
|
||
expect($entry->press_release_id)->toBe($pr->id);
|
||
expect($entry->user_id)->toBe($customer->id);
|
||
expect($entry->company_id)->toBe($company->id);
|
||
expect($entry->text_declaration)->toBe(AiTextDeclaration::MostlyAi);
|
||
expect($entry->confirmed_at)->not->toBeNull();
|
||
expect($entry->created_at)->not->toBeNull();
|
||
expect($entry->image_declarations)->toBe([
|
||
['image_id' => $image->id, 'path' => $image->path, 'declaration' => 'ai_generated'],
|
||
]);
|
||
|
||
// Re-Submit nach Ablehnung erzeugt einen ZWEITEN Eintrag (Versionierung).
|
||
$pr->fresh()->update(['status' => PressReleaseStatus::Rejected->value]);
|
||
app(PressReleaseService::class)->submitForReview($pr->fresh());
|
||
|
||
expect(PressReleaseAiDeclaration::query()->count())->toBe(2);
|
||
});
|
||
|
||
test('the create editor requires the ai declaration only when submitting for review', function () {
|
||
/** @var TestCase $this */
|
||
[$customer] = makeAiDeclarationCustomer();
|
||
$category = Category::factory()->create();
|
||
|
||
$this->actingAs($customer);
|
||
|
||
$component = Livewire::test('customer.press-releases.create')
|
||
->set('categoryId', $category->id)
|
||
->set('title', 'Ein gültiger Titel für die PM')
|
||
->set('text', str_repeat('Aussagekräftiger Fließtext. ', 5));
|
||
|
||
// Einreichen ohne Deklaration → Fehler im Modal, keine PM angelegt.
|
||
$component
|
||
->call('save', 'review')
|
||
->assertHasErrors(['aiTextDeclaration', 'aiDeclarationConfirmed']);
|
||
|
||
expect(PressRelease::query()->where('user_id', $customer->id)->count())->toBe(0);
|
||
|
||
// Entwurf-Speichern bleibt ohne Deklaration möglich.
|
||
$component
|
||
->call('save', 'draft')
|
||
->assertHasNoErrors();
|
||
|
||
$draft = PressRelease::query()->where('user_id', $customer->id)->sole();
|
||
expect($draft->ai_text_declaration)->toBeNull();
|
||
expect($draft->ai_declaration_confirmed_at)->toBeNull();
|
||
});
|
||
|
||
test('submitting from the create editor persists the declaration and the audit entry', function () {
|
||
/** @var TestCase $this */
|
||
[$customer] = makeAiDeclarationCustomer();
|
||
$category = Category::factory()->create();
|
||
|
||
$this->actingAs($customer);
|
||
|
||
Livewire::test('customer.press-releases.create')
|
||
->set('categoryId', $category->id)
|
||
->set('title', 'Ein gültiger Titel für die PM')
|
||
->set('text', str_repeat('Aussagekräftiger Fließtext. ', 5))
|
||
->set('aiTextDeclaration', AiTextDeclaration::FullyAi->value)
|
||
->set('aiDeclarationConfirmed', true)
|
||
->call('save', 'review')
|
||
->assertHasNoErrors();
|
||
|
||
$pr = PressRelease::query()->where('user_id', $customer->id)->sole();
|
||
|
||
expect($pr->status)->toBe(PressReleaseStatus::Review);
|
||
expect($pr->ai_text_declaration)->toBe(AiTextDeclaration::FullyAi);
|
||
expect($pr->ai_declaration_confirmed_at)->not->toBeNull();
|
||
expect($pr->aiTextPublicLabel())
|
||
->toBe('KI-generiert: Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung ganz oder überwiegend mit generativer KI erstellt.');
|
||
|
||
$entry = PressReleaseAiDeclaration::query()->sole();
|
||
expect($entry->press_release_id)->toBe($pr->id);
|
||
expect($entry->text_declaration)->toBe(AiTextDeclaration::FullyAi);
|
||
});
|
||
|
||
test('submitting from the detail page validates and persists the declaration', function () {
|
||
/** @var TestCase $this */
|
||
[$customer, $company] = makeAiDeclarationCustomer();
|
||
|
||
$pr = PressRelease::factory()->aiUndeclared()->create([
|
||
'user_id' => $customer->id,
|
||
'company_id' => $company->id,
|
||
'portal' => $company->portal->value,
|
||
'status' => PressReleaseStatus::Draft->value,
|
||
]);
|
||
|
||
$this->actingAs($customer);
|
||
|
||
Livewire::test('customer.press-releases.show', ['id' => $pr->id])
|
||
->call('submitForReview')
|
||
->assertHasErrors(['aiTextDeclaration', 'aiDeclarationConfirmed']);
|
||
|
||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft);
|
||
|
||
Livewire::test('customer.press-releases.show', ['id' => $pr->id])
|
||
->set('aiTextDeclaration', AiTextDeclaration::Assisted->value)
|
||
->set('aiDeclarationConfirmed', true)
|
||
->call('submitForReview')
|
||
->assertHasNoErrors();
|
||
|
||
$pr->refresh();
|
||
expect($pr->status)->toBe(PressReleaseStatus::Review);
|
||
expect($pr->ai_text_declaration)->toBe(AiTextDeclaration::Assisted);
|
||
// Formulierungshilfe = Entlastungsfall → kein öffentliches Pflicht-Label.
|
||
expect($pr->aiTextPublicLabel())->toBeNull();
|
||
expect(PressReleaseAiDeclaration::query()->count())->toBe(1);
|
||
});
|
||
|
||
test('the submit modal renders the declaration options and the binding confirmation', function () {
|
||
/** @var TestCase $this */
|
||
[$customer, $company] = makeAiDeclarationCustomer();
|
||
|
||
$pr = PressRelease::factory()->aiUndeclared()->create([
|
||
'user_id' => $customer->id,
|
||
'company_id' => $company->id,
|
||
'portal' => $company->portal->value,
|
||
'status' => PressReleaseStatus::Draft->value,
|
||
]);
|
||
|
||
$this->actingAs($customer);
|
||
|
||
Livewire::test('customer.press-releases.show', ['id' => $pr->id])
|
||
->assertSee('KI-Kennzeichnung')
|
||
->assertSee('Ohne generative KI erstellt – oder KI nur als Formulierungshilfe, mit anschließender menschlicher Überarbeitung und Verantwortung.')
|
||
->assertSee('Ganz oder überwiegend mit generativer KI erstellt.')
|
||
->assertSee('Ich bestätige, dass meine Angaben zum Einsatz generativer KI vollständig und wahrheitsgemäß sind.');
|
||
});
|
||
|
||
test('image uploads require the per-image ai declaration', function () {
|
||
/** @var TestCase $this */
|
||
[$customer, $company] = makeAiDeclarationCustomer();
|
||
|
||
$pr = PressRelease::factory()->create([
|
||
'user_id' => $customer->id,
|
||
'company_id' => $company->id,
|
||
'category_id' => Category::factory()->create()->id,
|
||
'portal' => $company->portal->value,
|
||
'status' => 'draft',
|
||
]);
|
||
|
||
Storage::fake('public');
|
||
Storage::fake('local');
|
||
$this->actingAs($customer);
|
||
|
||
Livewire::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||
->set('newCopyright', 'Foto: Jane Doe')
|
||
->set('newAuthor', 'Jane Doe')
|
||
->set('newLicenseType', ImageLicenseType::Own->value)
|
||
->set('newPeopleRightsStatus', 'none')
|
||
->set('newPropertyRightsStatus', 'none')
|
||
->set('newRightsConfirmed', true)
|
||
->call('saveImage')
|
||
->assertHasErrors(['newAiDeclaration']);
|
||
|
||
expect($pr->images()->count())->toBe(0);
|
||
});
|