presseportale/tests/Feature/Images/PressReleaseImageRightsV2Test.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

514 lines
21 KiB
PHP

<?php
use App\Enums\AiImageDeclaration;
use App\Enums\ConfirmationChannel;
use App\Enums\Portal;
use App\Mail\PressReleaseImageConfirmationRequested;
use App\Models\ArchiveEntry;
use App\Models\Category;
use App\Models\Company;
use App\Models\ImageRightsConfirmation;
use App\Models\LegalDocument;
use App\Models\LegalText;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Services\PressRelease\Images\CustomerImageConfirmation;
use App\Services\PressRelease\Images\ImageUploadException;
use Database\Seeders\LegalDocumentSeeder;
use Database\Seeders\LegalTextSeeder;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Bildkennzeichnung Block B: Formular v2 im Titelbild-Manager, Admin-Upload
* mit Kundenbestätigung, Entfernen mit Sofortlöschung.
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$this->seed(LegalDocumentSeeder::class);
$this->seed(LegalTextSeeder::class);
Storage::fake('public');
Storage::fake('local');
config()->set('images.rights_v2', true);
});
/**
* @return array{owner: User, pr: PressRelease, company: Company}
*/
function makeRightsV2Draft(): array
{
$owner = User::factory()->create(['is_active' => true]);
$owner->assignRole('customer');
$company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG']);
$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' => 'draft',
]);
return compact('owner', 'pr', 'company');
}
function rightsV2Fixture(string $name): UploadedFile
{
return UploadedFile::fake()->createWithContent($name, (string) file_get_contents(base_path('tests/Fixtures/images/'.$name)));
}
/**
* Veröffentlichte PM mit öffentlicher Detailseite auf presseecho.
*
* @return array{owner: User, pr: PressRelease, company: Company}
*/
function makeRightsV2Published(): array
{
$owner = User::factory()->create(['is_active' => true]);
$owner->assignRole('customer');
$category = Category::factory()->create(['portal' => Portal::Presseecho]);
$category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie-'.$category->id]);
$company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG', 'is_active' => true]);
$owner->companies()->attach($company->id, ['role' => 'owner']);
$pr = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)->create([
'user_id' => $owner->id,
'slug' => 'rights-v2-'.fake()->unique()->numberBetween(1, 99999),
'published_at' => now()->subDay(),
'published_ever_at' => now()->subDay(),
]);
return compact('owner', 'pr', 'company');
}
/**
* Simuliert Deploy 3: neue AGB-Fassung ab heute samt daran gebundenem
* Bestätigungstext. LegalText::currentFor wählt den an die aktuelle
* AGB-Fassung gebundenen Text, bisher gilt img-confirm-v2 (AGB 0.6).
*/
function activateRightsTextV4(): void
{
LegalDocument::factory()->agb()->create(['version' => '0.9-test', 'effective_from' => today()]);
$body = 'Neue Fassung der Bestätigung der Bildrechte (AGB {agb_version}).';
LegalText::query()->create([
'key' => 'img-confirm-v4',
'type' => LegalText::TYPE_IMAGE_RIGHTS_CONFIRMATION,
'locale' => 'de',
'title' => 'Bestätigung der Bildrechte',
'body' => $body,
'short_body' => 'Ich bestätige die Rechte am Bild (AGB {agb_version}).',
'bound_agb_version' => '0.9-test',
'body_sha256' => hash('sha256', $body),
'effective_from' => today(),
'note' => 'Testfassung',
]);
}
test('the edit page embeds the v2 manager only while the flag is on', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
$this->actingAs($owner);
Livewire::test('customer.press-releases.edit', ['id' => $pr->id])
->assertSeeLivewire('components.press-release-images-manager-v2');
config()->set('images.rights_v2', false);
Livewire::test('customer.press-releases.edit', ['id' => $pr->id])
->assertSeeLivewire('components.press-release-images-manager')
->assertDontSeeLivewire('components.press-release-images-manager-v2');
});
test('nachweis, kennzeichnung and the confirmation are the only required inputs', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
$this->actingAs($owner);
Livewire::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->call('openUploadForm')
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
->set('newCopyright', '')
->call('saveImage')
->assertHasErrors(['newCopyright', 'newAiDeclaration', 'newRightsConfirmed'])
->assertHasNoErrors(['newTitle', 'newLicenseUrl', 'newSourceUrl']);
expect($pr->images()->count())->toBe(0);
});
test('without metadata the company name is suggested as credit and the state says so', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
$this->actingAs($owner);
Livewire::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->call('openUploadForm')
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
->assertSet('newCopyright', 'Beispiel AG')
->assertSet('provenanceStatus', 'absent')
->assertSee('Keine Herkunftsdaten in der Datei gefunden.');
});
test('iptc and xmp metadata prefill credit and ai declaration and can be changed', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
$this->actingAs($owner);
$component = Livewire::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->call('openUploadForm')
->set('newImage', rightsV2Fixture('camera-iptc.jpg'))
->assertSet('newCopyright', 'Musterfirma GmbH / Anna Beispiel')
->assertSet('newAiDeclaration', '')
->assertSee('Angaben aus den Metadaten der Datei vorbelegt.');
$component
->set('newImage', rightsV2Fixture('xmp-ai-generated.jpg'))
->assertSet('newCopyright', 'KI-Bild / Musterfirma')
->assertSet('newAiDeclaration', 'ai_generated')
->assertSee('Sie können die Auswahl ändern')
->set('newAiDeclaration', 'original');
expect($component->get('newAiDeclaration'))->toBe('original');
});
test('a manual credit survives a file change but the confirmation toggle does not', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
$this->actingAs($owner);
Livewire::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->call('openUploadForm')
->set('newImage', UploadedFile::fake()->image('a.jpg', 640, 480))
->set('newCopyright', 'Eigener Nachweis')
->set('newRightsConfirmed', true)
->set('newImage', rightsV2Fixture('camera-iptc.jpg'))
->assertSet('newCopyright', 'Eigener Nachweis')
->assertSet('newRightsConfirmed', false);
});
test('an unreadable file shows the error state and is not accepted', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
$this->actingAs($owner);
Livewire::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->call('openUploadForm')
->set('newImage', rightsV2Fixture('broken.jpg'))
->assertHasErrors(['newImage']);
});
test('a valid upload stores the image under the new rules with a confirmation and shows it on the card', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
$this->actingAs($owner);
Livewire::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->call('openUploadForm')
->set('newImage', rightsV2Fixture('camera-iptc.jpg'))
->set('newAiDeclaration', AiImageDeclaration::Original->value)
->set('newTitle', 'Werkshalle')
->set('newLicenseUrl', 'https://example.com/lizenz')
->set('focus', '100,0')
->set('newRightsConfirmed', true)
->call('saveImage')
->assertHasNoErrors()
->assertSet('isUploadFormOpen', false)
->assertDispatched('title-image-changed')
->assertSee('Rechte bestätigt am')
->assertSee('img-confirm-v2');
$image = $pr->images()->sole();
expect($image->rules_version)->toBe(config('images.rules_version'))
->and($image->copyright)->toBe('Musterfirma GmbH / Anna Beispiel')
->and($image->credit_source?->value)->toBe('metadata')
->and($image->title)->toBe('Werkshalle')
->and($image->license_url)->toBe('https://example.com/lizenz')
->and($image->focus_x)->toBe(100)
->and($image->focus_y)->toBe(0)
->and($image->license_type)->toBeNull()
->and($image->people_rights_status)->toBeNull()
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue()
->and($image->hasValidRightsConfirmation())->toBeTrue();
$confirmation = ImageRightsConfirmation::query()->sole();
expect($confirmation->channel)->toBe(ConfirmationChannel::Form)
->and($confirmation->user_id)->toBe($owner->id)
->and($confirmation->legal_text_key)->toBe('img-confirm-v2')
->and($confirmation->text_snapshot)->toContain('/de/agb');
});
test('an admin upload skips the confirmation, mails the customer and blocks nothing else', function () {
/** @var TestCase $this */
Mail::fake();
['pr' => $pr, 'owner' => $owner] = makeRightsV2Draft();
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
Livewire::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->call('openUploadForm')
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
->set('newCopyright', 'Beispiel AG')
->set('newAiDeclaration', AiImageDeclaration::Original->value)
->assertSee('Sie laden im Auftrag des Kunden hoch')
->call('saveImage')
->assertHasNoErrors()
->assertSee('wartet auf Kundenbestätigung');
$image = $pr->images()->sole();
expect($image->customer_confirmation_requested_at)->not->toBeNull()
->and($image->awaitsCustomerConfirmation())->toBeTrue()
->and(ImageRightsConfirmation::query()->count())->toBe(0);
Mail::assertQueued(PressReleaseImageConfirmationRequested::class, fn ($mail) => $mail->hasTo($owner->email) && $mail->image->is($image));
});
test('the customer confirms an admin upload on the detail page with the magic link channel', function () {
/** @var TestCase $this */
['pr' => $pr, 'owner' => $owner] = makeRightsV2Draft();
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create([
'rights_confirmed_at' => null,
'customer_confirmation_requested_at' => now(),
]);
$this->actingAs($owner);
Livewire::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Titelbild bestätigen')
->call('confirmImageRights')
->assertHasErrors(['imageRightsConfirmed'])
->set('imageRightsConfirmed', true)
->call('confirmImageRights')
->assertHasNoErrors()
->assertDontSee('Titelbild bestätigen');
$confirmation = $image->rightsConfirmations()->sole();
expect($confirmation->channel)->toBe(ConfirmationChannel::MagicLink)
->and($confirmation->user_id)->toBe($owner->id)
->and($confirmation->file_sha256)->toBe($image->original_sha256)
->and($image->fresh()->awaitsCustomerConfirmation())->toBeFalse()
->and($image->fresh()->rights_confirmed_at)->not->toBeNull();
});
test('removing a never published image deletes original, derivatives and confirmation for good', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
$this->actingAs($owner);
$component = Livewire::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->call('openUploadForm')
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
->set('newAiDeclaration', AiImageDeclaration::Original->value)
->set('newRightsConfirmed', true)
->call('saveImage')
->assertHasNoErrors();
$image = $pr->images()->sole();
expect(Storage::disk('local')->allFiles())->not->toBe([]);
$component->call('remove', $image->id)->assertDispatched('title-image-changed');
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->exists())->toBeFalse()
->and(ImageRightsConfirmation::query()->count())->toBe(0)
->and(Storage::disk('local')->allFiles())->toBe([]);
});
test('removing a once published image moves original and confirmation into the evidence archive and only soft deletes', function () {
/** @var TestCase $this */
Storage::fake('archive');
config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32)));
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
Livewire::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->call('openUploadForm')
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
->set('newCopyright', 'Beispiel AG')
->set('newAiDeclaration', AiImageDeclaration::Original->value)
->call('saveImage')
->assertHasNoErrors();
$image = $pr->images()->sole();
$image->forceFill(['published_ever_at' => now()])->save();
Livewire::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->call('remove', $image->id);
$trashed = PressReleaseImage::withTrashed()->whereKey($image->id)->sole();
// Neue Semantik (Umsetzungsaudit 25.09.2026): Original und Bestätigung
// liegen im Beweisarchiv, die lokale Beweisdatei ist weg, der Pfad an
// der Zeile geleert (Bestätigungszeilen bleiben bis zum Fristende —
// siehe EvidenceArchiveTest).
expect($trashed->trashed())->toBeTrue()
->and(ArchiveEntry::query()->where('event', 'image_removed')->where('subject_id', $image->id)->exists())->toBeTrue()
->and(Storage::disk('local')->exists($image->original_path))->toBeFalse()
->and($trashed->original_path)->toBeNull()
->and(Storage::disk('local')->exists($image->path))->toBeFalse();
});
test('an admin upload on a published press release stays hidden on page, og and media route until the owner confirms it', function () {
/** @var TestCase $this */
Mail::fake();
['pr' => $pr, 'owner' => $owner] = makeRightsV2Published();
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
Livewire::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->call('openUploadForm')
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
->set('newCopyright', 'Beispiel AG')
->set('newAiDeclaration', AiImageDeclaration::Original->value)
->call('saveImage')
->assertHasNoErrors();
$image = $pr->images()->sole();
$mediaUrl = route('media.press-release-images.show', ['image' => $image->id, 'variant' => 'cover']);
$pageUrl = 'https://presseecho.test/de/press-release/'.$pr->slug;
expect($image->isPubliclyDeliverable())->toBeFalse()
->and(PressReleaseImage::query()->publiclyVisible()->whereKey($image->id)->exists())->toBeFalse();
// Berechtigte interne Ansicht bleibt, aber ohne öffentlichen Cache.
$this->get($mediaUrl)->assertSuccessful()->assertHeader('Cache-Control', 'no-store, private');
auth()->logout();
$this->get($pageUrl)->assertSuccessful()->assertDontSee('media/press-release-images/'.$image->id, false);
$this->get($mediaUrl)->assertNotFound();
$this->actingAs($owner);
Livewire::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Titelbild bestätigen')
->set('imageRightsConfirmed', true)
->call('confirmImageRights')
->assertHasNoErrors();
$image = $image->fresh();
expect($image->rights_confirmed_at)->not->toBeNull()
->and($image->isPubliclyDeliverable())->toBeTrue()
->and($image->published_snapshot_path)->not->toBeNull()
->and($image->rightsConfirmations()->sole()->channel)->toBe(ConfirmationChannel::MagicLink);
auth()->logout();
$this->get($pageUrl)
->assertSuccessful()
->assertSee('<meta property="og:image" content="'.route('media.press-release-images.show', ['image' => $image->id, 'variant' => 'og']).'">', false);
$this->get($mediaUrl)->assertSuccessful()->assertHeader('Cache-Control', 'max-age=86400, public');
});
test('only the account owner may confirm an admin upload: team member, super admin and a repeat are refused', function () {
/** @var TestCase $this */
['pr' => $pr, 'owner' => $owner, 'company' => $company] = makeRightsV2Draft();
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create([
'rights_confirmed_at' => null,
'customer_confirmation_requested_at' => now(),
]);
$member = User::factory()->create(['is_active' => true]);
$member->assignRole('customer');
$member->companies()->attach($company->id, ['role' => 'member']);
$superAdmin = User::factory()->superAdmin()->create(['is_active' => true]);
expect($member->can('update', $pr))->toBeTrue()
->and($member->can('confirmImageRights', $pr))->toBeFalse()
->and($superAdmin->can('update', $pr))->toBeTrue()
->and($superAdmin->can('confirmImageRights', $pr))->toBeFalse()
->and($owner->can('confirmImageRights', $pr))->toBeTrue();
$this->actingAs($member);
Livewire::test('customer.press-releases.show', ['id' => $pr->id])
->assertDontSee('Titelbild bestätigen')
->set('imageRightsConfirmed', true)
->call('confirmImageRights')
->assertForbidden();
$confirm = app(CustomerImageConfirmation::class);
// Der Service prüft die Identität unabhängig von der Policy.
expect(fn () => $confirm->confirm($image, $member, 'img-confirm-v2', null, null))
->toThrow(ImageUploadException::class, 'Kontoinhaber');
expect(ImageRightsConfirmation::query()->count())->toBe(0);
$confirm->confirm($image->fresh(), $owner, 'img-confirm-v2', null, null);
expect(fn () => $confirm->confirm($image->fresh(), $owner, 'img-confirm-v2', null, null))
->toThrow(ImageUploadException::class, 'keine Bestätigung aus');
expect(ImageRightsConfirmation::query()->count())->toBe(1);
});
test('a confirmation text replaced between render and save must be read and confirmed again in the manager', function () {
/** @var TestCase $this */
['pr' => $pr, 'owner' => $owner] = makeRightsV2Draft();
$this->actingAs($owner);
$component = Livewire::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->assertSet('legalTextKey', 'img-confirm-v2')
->call('openUploadForm')
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
->set('newCopyright', 'Beispiel AG')
->set('newAiDeclaration', AiImageDeclaration::Original->value)
->set('newRightsConfirmed', true);
activateRightsTextV4();
$component->call('saveImage')
->assertHasErrors(['newRightsConfirmed'])
->assertSet('newRightsConfirmed', false)
->assertSet('legalTextKey', 'img-confirm-v4');
expect($pr->images()->count())->toBe(0);
$component->set('newRightsConfirmed', true)
->call('saveImage')
->assertHasNoErrors();
expect($pr->images()->sole()->rightsConfirmations()->sole()->legal_text_key)->toBe('img-confirm-v4');
});
test('the confirmation on the detail page is bound to the text version shown', function () {
/** @var TestCase $this */
['pr' => $pr, 'owner' => $owner] = makeRightsV2Draft();
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create([
'rights_confirmed_at' => null,
'customer_confirmation_requested_at' => now(),
]);
$this->actingAs($owner);
$component = Livewire::test('customer.press-releases.show', ['id' => $pr->id])
->assertSet('imageLegalTextKey', 'img-confirm-v2')
->set('imageRightsConfirmed', true);
activateRightsTextV4();
$component->call('confirmImageRights')
->assertHasErrors(['imageRightsConfirmed'])
->assertSet('imageRightsConfirmed', false)
->assertSet('imageLegalTextKey', 'img-confirm-v4');
expect(ImageRightsConfirmation::query()->count())->toBe(0);
$component->set('imageRightsConfirmed', true)
->call('confirmImageRights')
->assertHasNoErrors();
expect($image->rightsConfirmations()->sole()->legal_text_key)->toBe('img-confirm-v4');
});