seed(LegalDocumentSeeder::class); $this->seed(LegalTextSeeder::class); }); function uploaderFixture(string $name): UploadedFile { $source = base_path('tests/Fixtures/images/'.$name); $copy = tempnam(sys_get_temp_dir(), 'pk-upload-'); copy($source, $copy); return new UploadedFile($copy, $name, mime_content_type($copy), null, true); } function uploaderSubmission(array $overrides = []): ImageSubmission { return new ImageSubmission(...array_merge([ 'copyright' => 'Musterfirma GmbH', 'aiDeclaration' => AiImageDeclaration::Original, 'rightsConfirmed' => true, 'legalTextKey' => 'img-confirm-v2', ], $overrides)); } function uploaderActor(PressRelease $pressRelease): ImageUploadActor { return ImageUploadActor::form($pressRelease->user, '203.0.113.9', 'PestBrowser/1.0'); } test('a form upload stores original, derivatives, provenance and a confirmation bound to the same hash', function () { $pressRelease = PressRelease::factory()->create(); $upload = uploaderFixture('camera-iptc.jpg'); $expectedHash = hash_file('sha256', $upload->getRealPath()); $image = app(PressReleaseImageUploader::class)->store( $upload, $pressRelease, uploaderSubmission(['creditFromSuggestion' => true]), uploaderActor($pressRelease), expectedSha256: $expectedHash, agbUrl: 'https://presseecho.test/de/agb', ); expect($image->rules_version)->toBe(config('images.rules_version')) ->and($image->original_sha256)->toBe($expectedHash) ->and($image->original_path)->toStartWith('press-release-originals/'.$pressRelease->id.'/') ->and(Storage::disk('local')->exists($image->original_path))->toBeTrue() ->and(Storage::disk('local')->exists($image->path))->toBeTrue() ->and($image->variants)->toHaveKeys(['thumb', 'medium', 'large', 'cover', 'og']) ->and($image->path)->not->toBe($image->original_path) ->and($image->provenance_status)->toBe(ImageProvenanceStatus::UnsignedMetadata) ->and($image->provenance['derived']['credit_suggestion'])->toBe('Musterfirma GmbH / Anna Beispiel') ->and($image->credit_source?->value)->toBe('metadata') ->and($image->ai_declaration)->toBe(AiImageDeclaration::Original) ->and($image->ai_declaration_user)->toBe(AiImageDeclaration::Original) ->and($image->ai_declaration_source)->toBe(AiDeclarationSource::Customer) ->and($image->ai_declaration_locked)->toBeFalse() ->and($image->rights_confirmed_at)->not->toBeNull() ->and($image->published_ever_at)->toBeNull() ->and($image->is_preview)->toBeTrue(); $confirmation = ImageRightsConfirmation::query()->sole(); expect($confirmation->file_sha256)->toBe($expectedHash) ->and($confirmation->legal_text_key)->toBe('img-confirm-v2') ->and($confirmation->agb_version)->toBe('0.6') ->and($confirmation->user_id)->toBe($pressRelease->user_id) ->and($confirmation->company_id)->toBe($pressRelease->company_id) ->and($confirmation->channel->value)->toBe('form') ->and($confirmation->text_snapshot)->toContain('https://presseecho.test/de/agb') ->and($confirmation->ip_hmac)->toStartWith('v1:') ->and($confirmation->declaration_snapshot['provenance_status'])->toBe('unsigned_metadata') ->and($image->fresh()->hasValidRightsConfirmation())->toBeTrue(); }); test('unsigned ai metadata only prefills: a lower user choice is stored as customer choice with a deviation mark', function () { $pressRelease = PressRelease::factory()->create(); $image = app(PressReleaseImageUploader::class)->store( uploaderFixture('xmp-ai-generated.jpg'), $pressRelease, uploaderSubmission(['aiDeclaration' => AiImageDeclaration::Original]), uploaderActor($pressRelease), ); expect($image->ai_declaration)->toBe(AiImageDeclaration::Original) ->and($image->ai_declaration_source)->toBe(AiDeclarationSource::Customer) ->and($image->ai_declaration_locked)->toBeFalse() ->and($image->provenance['deviated_from_prefill'])->toBeTrue() ->and($image->provenance['derived']['ai_declaration_suggestion'])->toBe('ai_generated'); }); test('accepting the prefilled ai suggestion is recorded as iptc_prefill', function () { $pressRelease = PressRelease::factory()->create(); $image = app(PressReleaseImageUploader::class)->store( uploaderFixture('xmp-ai-generated.jpg'), $pressRelease, uploaderSubmission(['aiDeclaration' => AiImageDeclaration::AiGenerated]), uploaderActor($pressRelease), ); expect($image->ai_declaration)->toBe(AiImageDeclaration::AiGenerated) ->and($image->ai_declaration_source)->toBe(AiDeclarationSource::IptcPrefill) ->and($image->provenance)->not->toHaveKey('deviated_from_prefill'); }); test('a changed file between preflight and store is rejected and leaves no files behind', function () { $pressRelease = PressRelease::factory()->create(); expect(fn () => app(PressReleaseImageUploader::class)->store( uploaderFixture('plain.png'), $pressRelease, uploaderSubmission(), uploaderActor($pressRelease), expectedSha256: hash('sha256', 'andere-datei'), ))->toThrow(ImageUploadException::class); expect(Storage::disk('local')->allFiles())->toBe([]) ->and($pressRelease->images()->count())->toBe(0); }); test('a database failure after the files were written removes original and derivatives again', function () { $pressRelease = PressRelease::factory()->create(); DB::listen(function ($query): void { if (str_starts_with(strtolower($query->sql), 'insert into "press_release_images"')) { throw new RuntimeException('simulierter DB-Fehler'); } }); expect(fn () => app(PressReleaseImageUploader::class)->store( uploaderFixture('plain.png'), $pressRelease, uploaderSubmission(), uploaderActor($pressRelease), ))->toThrow(RuntimeException::class, 'simulierter DB-Fehler'); expect(Storage::disk('local')->allFiles())->toBe([]) ->and(ImageRightsConfirmation::query()->count())->toBe(0); }); test('the one title image rule holds inside the locked transaction', function () { $pressRelease = PressRelease::factory()->create(); $uploader = app(PressReleaseImageUploader::class); $uploader->store(uploaderFixture('plain.png'), $pressRelease, uploaderSubmission(), uploaderActor($pressRelease)); expect(fn () => $uploader->store(uploaderFixture('camera-iptc.jpg'), $pressRelease, uploaderSubmission(), uploaderActor($pressRelease))) ->toThrow(ImageUploadException::class); expect($pressRelease->images()->count())->toBe(1) ->and(count(Storage::disk('local')->allFiles('press-release-originals')))->toBe(1); }); test('without the confirmation toggle nothing is stored', function () { $pressRelease = PressRelease::factory()->create(); expect(fn () => app(PressReleaseImageUploader::class)->store( uploaderFixture('plain.png'), $pressRelease, uploaderSubmission(['rightsConfirmed' => false]), uploaderActor($pressRelease), ))->toThrow(ImageUploadException::class); expect(Storage::disk('local')->allFiles())->toBe([]); }); test('an outdated confirmation text key is rejected with the current key in the message', function () { $pressRelease = PressRelease::factory()->create(); expect(fn () => app(PressReleaseImageUploader::class)->store( uploaderFixture('plain.png'), $pressRelease, uploaderSubmission(['legalTextKey' => 'img-confirm-v1']), uploaderActor($pressRelease), ))->toThrow(ImageUploadException::class, 'img-confirm-v2'); }); test('an admin upload stores no confirmation and waits for the customer', function () { $pressRelease = PressRelease::factory()->create(); $admin = User::factory()->create(); $image = app(PressReleaseImageUploader::class)->store( uploaderFixture('plain.png'), $pressRelease, uploaderSubmission(['rightsConfirmed' => false, 'legalTextKey' => null]), ImageUploadActor::admin($admin, '203.0.113.1', 'AdminBrowser'), ); expect(ImageRightsConfirmation::query()->count())->toBe(0) ->and($image->rights_confirmed_at)->toBeNull() ->and($image->customer_confirmation_requested_at)->not->toBeNull() ->and($image->awaitsCustomerConfirmation())->toBeTrue(); }); test('oversized uploads are refused before any processing', function () { $pressRelease = PressRelease::factory()->create(); config()->set('images.max_pixels_per_side', 32); expect(fn () => app(PressReleaseImageUploader::class)->store( uploaderFixture('camera-iptc.jpg'), // 64 × 48 px $pressRelease, uploaderSubmission(), uploaderActor($pressRelease), ))->toThrow(ImageUploadException::class, 'Pixel'); expect(Storage::disk('local')->allFiles())->toBe([]); }); test('an upload to an already published press release is marked as ever published', function () { $pressRelease = PressRelease::factory()->published()->create(); $image = app(PressReleaseImageUploader::class)->store( uploaderFixture('plain.png'), $pressRelease, uploaderSubmission(), uploaderActor($pressRelease), ); expect($image->published_ever_at)->not->toBeNull(); }); test('a failed derivative generation never leaves the source file public', function () { $pressRelease = PressRelease::factory()->create(); $upload = uploaderFixture('broken.jpg'); expect(fn () => app(PressReleaseImageUploader::class)->store( $upload, $pressRelease, uploaderSubmission(), uploaderActor($pressRelease), ))->toThrow(ImageUploadException::class); expect(Storage::disk('local')->allFiles())->toBe([]); });