presseportale/tests/Feature/Images/PressReleaseImageUploaderTest.php
Kevin Adametz 214aea8910 images: Datenmodell, Textarchiv, Herkunftsprüfung und gemeinsamer Uploader für Formular v2 (Bildkennzeichnung Block A)
- press_release_images: Kundenangabe/Portalkennzeichnung getrennt, Herkunftsbefund, Beweisoriginal, Snapshot-Pfade, published_ever_at, rules_version, Fokus/Letterbox; Bestand unangetastet
- legal_texts (insert-only) mit img-confirm-v1 (alter Wortlaut), v2 (AGB 0.6), v3 (AGB 0.7, noch ohne Datum); image-rights:backfill-confirmations
- image_rights_confirmations polymorph, insert-only, IP als versionierter HMAC; Löschung nur für nie veröffentlichte Uploads
- ImageProvenanceInspector (Imagick/exif): IPTC, XMP, EXIF; algorithmicallyEnhanced nur Vermerk; unlesbare Datei = error
- PressReleaseImageUploader: Original vor Derivaten, Hash-Bindung, Titelbild-Regel unter Sperre, Dateien bei Fehlern zurückgerollt; Admin-Upload ohne Bestätigung
- ImageService: kein Rückfall auf die Quelldatei, og-Variante, Fokuspunkt und no_crop

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:22:05 +00:00

257 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\AiDeclarationSource;
use App\Enums\AiImageDeclaration;
use App\Enums\ImageProvenanceStatus;
use App\Models\ImageRightsConfirmation;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\PressRelease\Images\ImageSubmission;
use App\Services\PressRelease\Images\ImageUploadActor;
use App\Services\PressRelease\Images\ImageUploadException;
use App\Services\PressRelease\Images\PressReleaseImageUploader;
use Database\Seeders\LegalDocumentSeeder;
use Database\Seeders\LegalTextSeeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
Storage::fake('local');
$this->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([]);
});