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([]); }); test('a status change during the image check is caught under the lock and leaves no files behind', function () { /** @var TestCase $this */ $pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Draft->value]); $real = app(ImageProvenanceInspector::class); // Die PM wird eingereicht und veröffentlicht, während die Herkunftsprüfung läuft. $this->instance(ImageProvenanceInspector::class, Mockery::mock(ImageProvenanceInspector::class, function (MockInterface $mock) use ($real, $pressRelease): void { $mock->shouldReceive('inspect')->andReturnUsing(function (string $path) use ($real, $pressRelease) { DB::table('press_releases')->where('id', $pressRelease->id)->update([ 'status' => PressReleaseStatus::Published->value, 'published_at' => now(), ]); return $real->inspect($path); }); })); expect(fn () => app(PressReleaseImageUploader::class)->store( uploaderFixture('plain.png'), $pressRelease, uploaderSubmission(), uploaderActor($pressRelease), allowedStatuses: [PressReleaseStatus::Draft, PressReleaseStatus::Rejected], ))->toThrow(ImageUploadException::class, 'Status der Meldung'); expect($pressRelease->images()->count())->toBe(0) ->and(Storage::disk('local')->allFiles())->toBe([]) ->and(ImageRightsConfirmation::query()->count())->toBe(0); }); test('a cache failure after the commit is reported but keeps the stored image and its files', function () { /** @var TestCase $this */ Exceptions::fake(); $pressRelease = PressRelease::factory()->published()->create(); // Proxy auf den echten CacheManager: nur die Generations-Erhöhung des // Seiten-Caches (WebPageCache::flush) schlägt fehl, alles andere läuft. $cache = Mockery::mock(Cache::getFacadeRoot())->makePartial(); $cache->shouldReceive('increment')->andThrow(new RuntimeException('cache down')); Cache::swap($cache); $image = app(PressReleaseImageUploader::class)->store( uploaderFixture('plain.png'), $pressRelease, uploaderSubmission(), uploaderActor($pressRelease), ); Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception->getMessage() === 'cache down'); expect($pressRelease->images()->count())->toBe(1) ->and(Storage::disk('local')->exists($image->original_path))->toBeTrue() ->and(Storage::disk('local')->exists($image->path))->toBeTrue() ->and(ImageRightsConfirmation::query()->count())->toBe(1); });