presseportale/tests/Feature/PressReleaseAiDeclarationTest.php
Kevin Adametz 6e84266f90 press-releases: Publish-Rechte, Quota-Races & Slug vereinheitlichen
- Publish/Reject/Archive und changeStatus erzwingen konsistent
  press-releases:publish; changeStatus schloss die Lücke, über die das
  Status-Modal die Prüfung umging (Fix#1/H2).
- publish()/changeStatusFromAdmin sperren PM- und User-Zeile
  transaktional; QuotaExceededException wird in Admin-Publish und im
  Auto-Publish-Job abgefangen (Warnung statt 500 / bleibt in Review, Fix#3/M2).
- Slug-Erzeugung nur noch über HasUniqueSlug (generateUniqueSlug +
  saveWithUniqueSlugRetry), redundanter Controller-Helper entfernt (M3).
- API-/Legacy-Text wird vor der Persistenz sanitisiert (M6);
  Scheduler/Legacy-Tracking ignorieren soft-gelöschte PMs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:47:51 +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\Volt\Volt as LivewireVolt;
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 = LivewireVolt::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);
LivewireVolt::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);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->call('submitForReview')
->assertHasErrors(['aiTextDeclaration', 'aiDeclarationConfirmed']);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft);
LivewireVolt::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);
LivewireVolt::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);
LivewireVolt::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);
});