diff --git a/tests/Feature/Images/PressReleaseImageRightsV2Test.php b/tests/Feature/Images/PressReleaseImageRightsV2Test.php
new file mode 100644
index 00000000..6b86460b
--- /dev/null
+++ b/tests/Feature/Images/PressReleaseImageRightsV2Test.php
@@ -0,0 +1,297 @@
+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();
+});