images: Titelbild-Formular v2, Admin-Upload mit Kundenbestätigung, Entfernen mit Sofortlöschung (Bildkennzeichnung Block B)

- press-release-images-manager-v2 hinter IMAGE_RIGHTS_V2: drei Blöcke, Vorbelegung aus Metadaten, Bestätigung mit archiviertem Wortlaut, Bild-Bindung über Hash, Fokuspunkt und Letterbox, Zustände absent/prefilled/error
- CustomerImageConfirmation: Magic-Link an den Kontoinhaber, Karte auf der Kundenseite, Kanal magic_link
- PressReleaseImageRemover: nie veröffentlicht = alles sofort weg, veröffentlicht = Soft Delete, Original bleibt
- ConversionSource::MailImageConfirmation

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 09:31:32 +00:00
parent 214aea8910
commit 3d3ddc2dc8
11 changed files with 1380 additions and 5 deletions

View file

@ -0,0 +1,297 @@
<?php
use App\Enums\AiImageDeclaration;
use App\Enums\ConfirmationChannel;
use App\Mail\PressReleaseImageConfirmationRequested;
use App\Models\Category;
use App\Models\Company;
use App\Models\ImageRightsConfirmation;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\User;
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\Volt\Volt as LivewireVolt;
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)));
}
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);
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
->assertSeeLivewire('components.press-release-images-manager-v2');
config()->set('images.rights_v2', false);
LivewireVolt::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);
LivewireVolt::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);
LivewireVolt::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 = LivewireVolt::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);
LivewireVolt::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);
LivewireVolt::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);
LivewireVolt::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);
LivewireVolt::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);
LivewireVolt::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 = LivewireVolt::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 keeps the original and the confirmation and only soft deletes', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
LivewireVolt::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();
LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->call('remove', $image->id);
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue()
->and(Storage::disk('local')->exists($image->path))->toBeFalse();
});