presseportale/tests/Feature/PressReleaseAiDeclarationTest.php
Kevin Adametz 2cde7284a1 livewire: Volt entfernt — Komponenten als Livewire-4-Single-File-Components, Route::livewire(), Livewire::test(); livewire/volt deinstalliert
- 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>
2026-10-06 13:59:36 +00:00

301 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
});