Bilder (N01, N05, N07, N08): gemeinsamer Freigabezustand PressReleaseImage::isPubliclyDeliverable() für Seiten, OG und Medienroute (Bestand bleibt sichtbar, v2 nur mit Bestätigung, Admin-Upload bis zum Kundenklick nicht öffentlich); eigene Ability confirmImageRights nur für den Kontoinhaber, ohne Super-Admin-Kurzschluss, Identität und ausstehende Bestätigung auch im Service; Status-Regel unter der PM-Sperre; Cache-Flush nach dem Commit außerhalb der Dateibereinigung; Snapshot nach Bestätigung an veröffentlichter PM; Bestätigungstexte an die gezeigte Kennung gebunden. Beweisarchiv (N02, N10, N11, N12, N13, 5.1): Tombstone unter Zeilensperre mit erneuter Hold-/Fristprüfung; Dateioperationen ausgewertet, Commands mit FAILURE; record() über .tmp + move; Marker archive_pending_at mit archive:retry-pending und archive:sweep-orphans; Anonymisierung entfernt Bilder über den Remover mit Antragsbezug; Draft-Purge räumt Medien nie veröffentlichter abgelehnter Einreichungen; App-Kopien fallen mit dem Archiv-Tombstone; label_corrected nur für veröffentlichte Bilder; archive_entries.legal_request_id; FileDeletion-Helfer; Systemstatus-Checks. KI und Hintergrund (N03, N04, N14, N20, N21): Ergebnis-UPDATE und Audit in einer Transaktion; erwarteter Hash bis in reject()/holdForManualReview(); Ablaufmail per beanspruchtem Marker in einer Transaktion; strikte Schemaprüfung der Vision-Antwort; Fortschrittsmarke ungecacht. Berichte und Canonical (N15–N19, 5.2): lückenlose Alterskohorten; Feed-Identität mit Portal und Sprache; Sitemap-Rolle in der Kundenansicht; Indexverteilung auf einer Grundgesamtheit; Kopien nach Wegfall der Erstquelle neu bewertet; Kategorie-Admin leert Seiten-Caches; Dry-Run zählt alle Tabellen. Gesamtsuite: 2.122 Tests grün, einer übersprungen; Larastan und Pint sauber. Zwei Migrationen (nullable Spalten, INSTANT). N09 offen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
318 lines
13 KiB
PHP
318 lines
13 KiB
PHP
<?php
|
||
|
||
use App\Enums\AiDeclarationSource;
|
||
use App\Enums\AiImageDeclaration;
|
||
use App\Enums\ImageProvenanceStatus;
|
||
use App\Enums\PressReleaseStatus;
|
||
use App\Models\ImageRightsConfirmation;
|
||
use App\Models\PressRelease;
|
||
use App\Models\User;
|
||
use App\Services\Image\ImageProvenanceInspector;
|
||
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\Cache;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Exceptions;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Mockery\MockInterface;
|
||
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([]);
|
||
});
|
||
|
||
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);
|
||
});
|