presseportale/tests/Feature/Images/ImagePipelineAuditTest.php
Kevin Adametz e77d9e7d09 feat: API-Zugang endet mit der Buchung, optionales Ablaufdatum für API-Tokens
Bisher blieb ein einmal erstellter Token gültig, auch wenn Abo bzw. Buchung
längst beendet waren. EnsureApiAccessIsBooked prüft jetzt bei jeder Anfrage
(v1 und /service/…-Altpfade), ob das Konto API-Zugang hat
(ApiAccessEligibilityService::hasApiAccess), sonst 403. Die Tokens bleiben
erhalten und funktionieren nach erneuter Buchung wieder.

Kunden können beim Erstellen optional ein Ablaufdatum setzen (bis 5 Jahre);
die Token-Liste zeigt „Gültig bis“ und einen Hinweis, wenn der Zugang
pausiert ist. sanctum:prune-expired räumt abgelaufene Tokens nach 30 Tagen
täglich ab. API-Doku (public/api/v1.yml) ergänzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:41:24 +00:00

846 lines
38 KiB
PHP

<?php
use App\Enums\AiImageDeclaration;
use App\Enums\ArchiveEvent;
use App\Enums\ConfirmationChannel;
use App\Enums\Portal;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Models\ArchiveEntry;
use App\Models\ArchivePendingJob;
use App\Models\Category;
use App\Models\Company;
use App\Models\ImageRightsConfirmation;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Services\Archive\EvidenceArchive;
use App\Services\Archive\PendingArchiveRetry;
use App\Services\Auth\MagicLinkGenerator;
use App\Services\Image\ImageProvenanceInspector;
use App\Services\Image\ImageService;
use App\Services\Image\Precheck\ImagePrecheckReviewService;
use App\Services\Image\Precheck\PrecheckImagePreparer;
use App\Services\PressRelease\Images\CustomerImageConfirmation;
use App\Services\PressRelease\Images\ImageCorrectionService;
use App\Services\PressRelease\Images\ImageSubmission;
use App\Services\PressRelease\Images\ImageUploadActor;
use App\Services\PressRelease\Images\ImageUploadException;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use App\Services\PressRelease\Images\PressReleaseImageUploader;
use App\Services\PressRelease\Images\PublishedImageSnapshot;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\ProofPdfRenderer;
use Database\Seeders\LegalDocumentSeeder;
use Database\Seeders\LegalTextSeeder;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Exceptions;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Mockery\MockInterface;
use Tests\Support\FailingDisk;
use Tests\TestCase;
/**
* Umsetzungsaudit 29.09.2026, Gruppe Bild-Pipeline: B-01 bis B-14, N09,
* K-02 (Routing nach Bildbestätigung) und OPT-3 bis OPT-5.
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$this->seed(LegalDocumentSeeder::class);
$this->seed(LegalTextSeeder::class);
Storage::fake('local');
Storage::fake('public');
Storage::fake('archive');
Mail::fake();
config()->set('images.rights_v2', true);
config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32)));
});
afterEach(function (): void {
FailingDisk::restore('local');
FailingDisk::restore('archive');
});
function pipelineAdmin(): User
{
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
return $admin;
}
/**
* @return array{owner: User, company: Company, pr: PressRelease}
*/
function pipelineRelease(string $status = 'published', array $attributes = []): array
{
$owner = User::factory()->create(['is_active' => true]);
$owner->assignRole('customer');
$category = Category::factory()->create(['portal' => Portal::Presseecho]);
$category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie-'.$category->id]);
$company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG', 'is_active' => true]);
$owner->companies()->attach($company->id, ['role' => 'owner']);
$published = $status === 'published';
$pr = PressRelease::factory()->forPortal(Portal::Presseecho)->for($category)->for($company)->create(array_merge([
'user_id' => $owner->id,
'status' => $status,
'language' => 'de',
'title' => 'Pipeline-Meldung '.fake()->unique()->numberBetween(1, 99999),
'slug' => 'pipeline-'.fake()->unique()->numberBetween(1, 99999),
'published_at' => $published ? now()->subHour() : null,
'published_ever_at' => $published ? now()->subHour() : null,
], $attributes));
return compact('owner', 'company', 'pr');
}
/**
* v2-Bild mit echten Derivaten (für OG-Neubau und Snapshot).
*/
function pipelineImage(PressRelease $pr, bool $confirmed = true, array $attributes = []): PressReleaseImage
{
$upload = UploadedFile::fake()->image('foto.jpg', 1600, 900)->size(100);
$stored = app(ImageService::class)->storePressReleaseImage($upload, $pr->id, 'local', ogCaption: 'Foto: Alt');
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(array_merge([
'disk' => 'local',
'path' => $stored['path'],
'variants' => $stored['variants'],
'copyright' => 'Alte Agentur',
'rights_confirmed_at' => $confirmed ? now() : null,
], $attributes));
Storage::disk('local')->put($image->original_path, 'original-bytes-'.$image->id);
if ($confirmed) {
ImageRightsConfirmation::query()->create([
'subject_type' => $image->getMorphClass(),
'subject_id' => $image->getKey(),
'press_release_id' => $pr->id,
'company_id' => $pr->company_id,
'user_id' => $pr->user_id,
'channel' => ConfirmationChannel::Form,
'legal_text_key' => 'img-confirm-v2',
'text_snapshot' => 'Ich bestätige …',
'agb_version' => '0.6',
'file_sha256' => $image->original_sha256,
'declaration_snapshot' => [],
'confirmed_at' => now(),
'created_at' => now(),
]);
}
return $image->fresh();
}
function pipelineFixture(string $name): UploadedFile
{
$copy = tempnam(sys_get_temp_dir(), 'pk-pipeline-');
copy(base_path('tests/Fixtures/images/'.$name), $copy);
return new UploadedFile($copy, $name, mime_content_type($copy), null, true);
}
function pipelineSubmission(): ImageSubmission
{
return new ImageSubmission(copyright: 'Musterfirma GmbH', aiDeclaration: AiImageDeclaration::Original, rightsConfirmed: true, legalTextKey: 'img-confirm-v2');
}
/**
* @return list<string>
*/
function pipelineArchiveFiles(ArchiveEntry $entry): array
{
$zipBytes = app(EvidenceArchive::class)->open($entry, pipelineAdmin(), 'Test');
$path = tempnam(sys_get_temp_dir(), 'pk-zip-');
file_put_contents($path, $zipBytes);
$zip = new ZipArchive;
$zip->open($path);
$names = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$names[] = (string) $zip->getNameIndex($i);
}
$zip->close();
@unlink($path);
return $names;
}
// ---------------------------------------------------------------- B-01 Feed
test('the start page feed shows the placeholder for a withheld and for an unconfirmed v2 image', function (array $attributes) {
['pr' => $pr] = pipelineRelease();
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(array_merge(['copyright' => 'Geheime Agentur'], $attributes));
Livewire::test('web.press-release-feed', ['portal' => Portal::Presseecho->value, 'language' => 'de'])
->assertSee($pr->title)
->assertDontSee(route('media.press-release-images.show', ['image' => $image->id]), false)
->assertDontSee('Geheime Agentur');
})->with([
'zurückgehalten (Korrektur)' => [['withheld_at' => now()]],
'zurückgehalten (Vorab-Sichtung)' => [['precheck_withheld_at' => now()]],
'Admin-Upload ohne Kundenbestätigung' => [['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]],
]);
test('the start page feed still shows a released image with its credit', function () {
['pr' => $pr] = pipelineRelease();
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['copyright' => 'Freie Agentur']);
Livewire::test('web.press-release-feed', ['portal' => Portal::Presseecho->value, 'language' => 'de'])
->assertSee(route('media.press-release-images.show', ['image' => $image->id]), false)
->assertSee('Foto: Freie Agentur');
});
test('the card helpers only return a publicly deliverable image and never lazy load the whole relation', function () {
['pr' => $pr] = pipelineRelease();
$withheld = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['withheld_at' => now(), 'is_preview' => true, 'sort_order' => 1]);
// Unvorgeladen: eigene gefilterte Abfrage, die Relation bleibt ungeladen.
$fresh = PressRelease::query()->findOrFail($pr->id);
expect($fresh->cardImageUrl())->toBeNull()
->and($fresh->thumbImageUrl())->toBeNull()
->and($fresh->cardImageCredit())->toBeNull()
->and($fresh->relationLoaded('images'))->toBeFalse();
// Ungefiltert vorgeladen (z. B. Kundenliste): trotzdem kein zurückgehaltenes Bild.
$loaded = PressRelease::query()->with('images')->findOrFail($pr->id);
expect($loaded->cardImageUrl())->toBeNull();
$released = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['copyright' => 'Zweitbild', 'is_preview' => false, 'sort_order' => 2]);
$loaded = PressRelease::query()->with('images')->findOrFail($pr->id);
expect($loaded->cardImageUrl())->toBe($released->url())
->and($loaded->cardImageCredit())->toContain('Zweitbild')
->and($withheld->isPubliclyDeliverable())->toBeFalse();
});
// ---------------------------------------------------------------- B-02 Nachweis-PDF
test('the proof pdf never embeds a withheld or unconfirmed title image', function (array $attributes) {
['pr' => $pr] = pipelineRelease();
pipelineImage($pr, confirmed: false, attributes: $attributes);
$pdf = app(ProofPdfRenderer::class)->render($pr->fresh());
expect($pdf)->not->toContain('/Subtype /Image');
})->with([
'zurückgehalten' => [['withheld_at' => now(), 'rights_confirmed_at' => now()]],
'unbestätigt' => [['customer_confirmation_requested_at' => now()]],
]);
// ---------------------------------------------------------------- B-03 Upload-Grenze
test('the livewire temporary upload rule allows the promised 16 mib', function () {
expect(config('livewire.temporary_file_upload.rules'))->toBe(['required', 'file', 'max:16384']);
});
test('a 13 mib file passes the livewire upload in the v2 manager', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
$this->actingAs($owner);
Livewire::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->call('openUploadForm')
->set('newImage', UploadedFile::fake()->image('gross.jpg', 1200, 800)->size(13 * 1024))
->assertHasNoErrors('newImage');
});
// ---------------------------------------------------------------- N09 Snapshot
test('a credit correction creates a new snapshot version and keeps the earlier one with its own sidecar', function () {
['pr' => $pr] = pipelineRelease('review');
$image = pipelineImage($pr);
app(PressReleaseService::class)->publish($pr);
$first = $image->fresh()->published_snapshot_path;
app(ImageCorrectionService::class)->correctCredit($image->fresh(), pipelineAdmin(), 'Neue Agentur', 'Einwand');
$second = $image->fresh()->published_snapshot_path;
$disk = Storage::disk('local');
$sidecar = fn (string $path): array => json_decode((string) $disk->get(preg_replace('/\.[a-z0-9]+$/i', '.json', $path)), true);
expect($first)->not->toBeNull()
->and($second)->not->toBeNull()
->and($second)->not->toBe($first)
->and($disk->exists($first))->toBeTrue()
->and($disk->exists($second))->toBeTrue()
->and($sidecar($first)['credit_line'])->toContain('Alte Agentur')
->and($sidecar($second)['credit_line'])->toContain('Neue Agentur')
->and($sidecar($second)['og_path'])->not->toBeNull()
->and($disk->exists($sidecar($second)['og_path']))->toBeTrue();
});
test('an ai declaration correction also versions the snapshot and an unchanged state is not duplicated', function () {
['pr' => $pr] = pipelineRelease('review');
$image = pipelineImage($pr);
app(PressReleaseService::class)->publish($pr);
$first = $image->fresh()->published_snapshot_path;
app(PublishedImageSnapshot::class)->capture($image->fresh());
expect($image->fresh()->published_snapshot_path)->toBe($first);
app(ImageCorrectionService::class)->correctAiDeclaration($image->fresh(), pipelineAdmin(), AiImageDeclaration::AiEdited, 'Hinweis');
expect($image->fresh()->published_snapshot_path)->not->toBe($first);
});
test('a failed snapshot write leaves no phantom path and does not break publishing', function () {
Exceptions::fake();
['pr' => $pr] = pipelineRelease('review');
$image = pipelineImage($pr);
FailingDisk::install('local', [
'put' => fn ($path, $real) => str_starts_with((string) $path, 'press-release-published/') ? false : $real(),
]);
app(PressReleaseService::class)->publish($pr);
FailingDisk::restore('local');
$image->refresh();
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published)
->and($image->published_snapshot_path)->toBeNull()
->and($image->published_ever_at)->not->toBeNull()
->and(Storage::disk('local')->allFiles('press-release-published'))->toBe([]);
Exceptions::assertReported(RuntimeException::class);
});
test('publishing marks only deliverable images as ever published and snapshots only those', function () {
['pr' => $pr] = pipelineRelease('review');
$withheld = pipelineImage($pr, attributes: ['withheld_at' => now()]);
app(PressReleaseService::class)->publish($pr);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published)
->and($withheld->fresh()->published_ever_at)->toBeNull()
->and($withheld->fresh()->published_snapshot_path)->toBeNull();
// Freigabe an der veröffentlichten Meldung: jetzt öffentlich, jetzt Snapshot.
app(ImageCorrectionService::class)->release($withheld->fresh(), pipelineAdmin(), 'Einwand geprüft');
expect($withheld->fresh()->published_ever_at)->not->toBeNull()
->and($withheld->fresh()->published_snapshot_path)->not->toBeNull();
});
test('removing an image archives every snapshot version', function () {
['pr' => $pr] = pipelineRelease('review');
$image = pipelineImage($pr);
app(PressReleaseService::class)->publish($pr);
app(ImageCorrectionService::class)->correctCredit($image->fresh(), pipelineAdmin(), 'Neue Agentur', 'Einwand');
app(PressReleaseImageRemover::class)->remove($image->fresh());
$entry = ArchiveEntry::query()->where('event', ArchiveEvent::ImageRemoved->value)->where('subject_id', $image->id)->sole();
$files = pipelineArchiveFiles($entry);
expect(collect($files)->filter(fn ($name) => str_starts_with($name, 'published-history/'))->count())->toBeGreaterThanOrEqual(2)
->and($files)->toContain('published.json')
->and(Storage::disk('local')->allFiles('press-release-published/'.$pr->id))->toBe([]);
});
// ---------------------------------------------------------------- B-04 Bindung an das angezeigte Bild
test('the customer confirmation is bound to the image shown and refuses a replaced one', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
$first = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
$this->actingAs($owner);
$component = Livewire::test('customer.press-releases.show', ['id' => $pr->id])
->assertSet('confirmImageId', $first->id)
->set('imageRightsConfirmed', true);
// Der Admin ersetzt das Bild, während der Kunde die Seite offen hat.
$first->forceDelete();
$second = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
$component->call('confirmImageRights')
->assertHasErrors(['imageRightsConfirmed'])
->assertSee('Bild wurde inzwischen geändert')
->assertSet('confirmImageId', $second->id);
expect(ImageRightsConfirmation::query()->count())->toBe(0);
$component->set('imageRightsConfirmed', true)
->call('confirmImageRights')
->assertHasNoErrors();
expect($second->rightsConfirmations()->count())->toBe(1);
});
test('the service refuses a confirmation whose file hash no longer matches', function () {
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
expect(fn () => app(CustomerImageConfirmation::class)->confirm($image, $owner, 'img-confirm-v2', null, null, null, hash('sha256', 'andere-datei')))
->toThrow(ImageUploadException::class, 'inzwischen geändert');
expect(ImageRightsConfirmation::query()->count())->toBe(0);
});
// ---------------------------------------------------------------- B-14 Sperre bei der Bestätigung
test('a stale image instance of a meanwhile removed image cannot be confirmed', function () {
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
$stale = PressReleaseImage::query()->findOrFail($image->id);
$image->delete();
expect(fn () => app(CustomerImageConfirmation::class)->confirm($stale, $owner, 'img-confirm-v2', null, null))
->toThrow(ImageUploadException::class);
expect(ImageRightsConfirmation::query()->count())->toBe(0);
});
// ---------------------------------------------------------------- K-02 Routing nach Bestätigung
test('a green release in review is routed again once the owner confirms the admin upload', function () {
['owner' => $owner, 'pr' => $pr] = pipelineRelease('review', ['classification' => PressReleaseClassification::Green->value]);
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
app(CustomerImageConfirmation::class)->confirm($image, $owner, 'img-confirm-v2', null, null);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published);
});
test('a release waiting for manual review or a future date is not routed after the confirmation', function (array $attributes) {
['owner' => $owner, 'pr' => $pr] = pipelineRelease('review', array_merge(['classification' => PressReleaseClassification::Green->value], $attributes));
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
app(CustomerImageConfirmation::class)->confirm($image, $owner, 'img-confirm-v2', null, null);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review);
})->with([
'manuelle Prüfung' => [['manual_review_required_at' => now()]],
'Termin in der Zukunft' => [['scheduled_at' => now()->addDay()]],
'rot' => [['classification' => PressReleaseClassification::Red->value]],
'ungeprüft' => [['classification' => null]],
]);
// ---------------------------------------------------------------- B-05 Entfernen unter Sperre
test('the remover re-checks the status rule under the lock', function () {
['pr' => $pr] = pipelineRelease('draft');
$image = pipelineImage($pr);
// Zwischen Seitenaufbau und Klick wurde die Meldung eingereicht.
DB::table('press_releases')->where('id', $pr->id)->update(['status' => PressReleaseStatus::Review->value]);
expect(fn () => app(PressReleaseImageRemover::class)->remove($image, allowedStatuses: [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]))
->toThrow(ImageUploadException::class);
expect(PressReleaseImage::query()->whereKey($image->id)->exists())->toBeTrue()
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue();
});
test('a stale never published flag does not hard delete an image that was published meanwhile', function () {
['pr' => $pr] = pipelineRelease('draft');
$image = pipelineImage($pr);
$stale = PressReleaseImage::query()->findOrFail($image->id);
DB::table('press_release_images')->where('id', $image->id)->update(['published_ever_at' => now()]);
app(PressReleaseImageRemover::class)->remove($stale);
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
->and(ArchiveEntry::query()->where('event', ArchiveEvent::ImageRemoved->value)->where('subject_id', $image->id)->exists())->toBeTrue();
});
test('the api refuses to remove an image once the status changed under the lock', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
$image = pipelineImage($pr);
$remover = app(PressReleaseImageRemover::class);
$this->instance(PressReleaseImageRemover::class, Mockery::mock(PressReleaseImageRemover::class, function (MockInterface $mock) use ($remover, $pr): void {
$mock->shouldReceive('remove')->andReturnUsing(function (...$arguments) use ($remover, $pr) {
DB::table('press_releases')->where('id', $pr->id)->update(['status' => PressReleaseStatus::Review->value]);
return $remover->remove(...$arguments);
});
}));
apiActingAs($owner, ['press-release-images:write']);
$this->deleteJson('/api/v1/press-release-images/'.$image->id)->assertStatus(409);
expect(PressReleaseImage::query()->whereKey($image->id)->exists())->toBeTrue();
});
// ---------------------------------------------------------------- B-06 / A-09 Korrektur
test('a label correction is archived first and a failing og rebuild only gets reported', function () {
/** @var TestCase $this */
Exceptions::fake();
['pr' => $pr] = pipelineRelease();
$image = pipelineImage($pr, attributes: ['published_ever_at' => now()->subHour()]);
$this->instance(ImageService::class, Mockery::mock(ImageService::class, function (MockInterface $mock): void {
$mock->shouldReceive('regenerateOgVariant')->andThrow(new RuntimeException('og kaputt'));
}));
$correction = app(ImageCorrectionService::class)->correctCredit($image, pipelineAdmin(), 'Neue Agentur', 'Einwand');
Exceptions::assertReported(fn (RuntimeException $e): bool => $e->getMessage() === 'og kaputt');
expect($correction->exists)->toBeTrue()
->and(ArchiveEntry::query()->where('event', ArchiveEvent::LabelCorrected->value)->count())->toBe(1)
->and(ArchivePendingJob::query()->count())->toBe(0)
->and($pr->fresh()->archive_pending_at)->toBeNull();
});
test('a failed label corrected archive leaves a pending job that the retry turns into the entry', function () {
['pr' => $pr] = pipelineRelease();
$image = pipelineImage($pr, attributes: ['published_ever_at' => now()->subHour()]);
FailingDisk::install('archive', ['put' => fn () => false]);
$correction = app(ImageCorrectionService::class)->correctCredit($image, pipelineAdmin(), 'Neue Agentur', 'Einwand');
FailingDisk::restore('archive');
$job = ArchivePendingJob::query()->sole();
expect($correction->exists)->toBeTrue()
->and($image->fresh()->copyright)->toBe('Neue Agentur')
->and(ArchiveEntry::query()->where('event', ArchiveEvent::LabelCorrected->value)->count())->toBe(0)
->and($job->event)->toBe(ArchiveEvent::LabelCorrected->value)
->and($job->subject_id)->toBe($correction->id)
->and($pr->fresh()->archive_pending_at)->not->toBeNull();
$result = app(PendingArchiveRetry::class)->retry($job);
expect($result['failed'])->toBe(0)
->and(ArchiveEntry::query()->where('event', ArchiveEvent::LabelCorrected->value)->sole()->summary['correction_id'])->toBe($correction->id)
->and(ArchivePendingJob::query()->count())->toBe(0)
->and($pr->fresh()->archive_pending_at)->toBeNull();
});
// ---------------------------------------------------------------- B-07 getrennte Rückhaltegründe
test('approving the precheck review keeps an image withheld by a correction', function () {
['pr' => $pr] = pipelineRelease();
$admin = pipelineAdmin();
$image = pipelineImage($pr, attributes: [
'precheck_review_required_at' => now(),
'precheck_withheld_at' => now(),
]);
app(ImageCorrectionService::class)->withhold($image->fresh(), $admin, 'Beschwerde');
app(ImagePrecheckReviewService::class)->approve($image->fresh(), pipelineAdmin(), 'Sichtung ok');
$image->refresh();
expect($image->awaitsPrecheckReview())->toBeFalse()
->and($image->isWithheld())->toBeTrue()
->and($image->isPubliclyDeliverable())->toBeFalse()
->and(PressReleaseImage::query()->publiclyVisible()->whereKey($image->id)->exists())->toBeFalse();
app(ImageCorrectionService::class)->release($image, $admin, 'Beschwerde erledigt');
expect($image->fresh()->isPubliclyDeliverable())->toBeTrue();
});
test('releasing a correction does not lift a pending precheck hold', function () {
['pr' => $pr] = pipelineRelease();
$admin = pipelineAdmin();
$image = pipelineImage($pr, attributes: [
'precheck_review_required_at' => now(),
'precheck_withheld_at' => now(),
]);
app(ImageCorrectionService::class)->withhold($image->fresh(), $admin, 'Beschwerde');
app(ImageCorrectionService::class)->release($image->fresh(), $admin, 'Beschwerde erledigt');
expect($image->fresh()->isPubliclyDeliverable())->toBeFalse()
->and($image->fresh()->isWithheld())->toBeTrue();
});
// ---------------------------------------------------------------- O03 Freigabe ohne Snapshot
test('an image released on a published release counts as published even if its snapshot fails', function (string $path) {
Exceptions::fake();
['pr' => $pr] = pipelineRelease();
$admin = pipelineAdmin();
$image = match ($path) {
'customer confirmation' => pipelineImage($pr, confirmed: false, attributes: ['customer_confirmation_requested_at' => now()]),
'precheck approval' => pipelineImage($pr, attributes: ['precheck_review_required_at' => now(), 'precheck_withheld_at' => now()]),
'correction release' => pipelineImage($pr, attributes: ['withheld_at' => now()]),
};
expect($image->fresh()->isPubliclyDeliverable())->toBeFalse()
->and($image->fresh()->published_ever_at)->toBeNull();
FailingDisk::install('local', [
'put' => fn ($target, $real) => str_starts_with((string) $target, 'press-release-published/') ? false : $real(),
]);
match ($path) {
'customer confirmation' => app(PressReleaseImageUploader::class)->confirmExisting($image->fresh(), ImageUploadActor::form($pr->user, null, null), 'img-confirm-v2'),
'precheck approval' => app(ImagePrecheckReviewService::class)->approve($image->fresh(), $admin, 'Sichtung ok'),
'correction release' => app(ImageCorrectionService::class)->release($image->fresh(), $admin, 'Einwand geprüft'),
};
FailingDisk::restore('local');
// Öffentlich auslieferbar heißt veröffentlicht (DU E5) — unabhängig vom
// Snapshot (Umsetzungsaudit 02.10.2026, O03).
expect($image->fresh()->isPubliclyDeliverable())->toBeTrue()
->and($image->fresh()->published_snapshot_path)->toBeNull()
->and($image->fresh()->published_ever_at)->not->toBeNull();
// Ein späteres Entfernen nimmt deshalb den Archivweg, nicht die Sofortlöschung.
app(PressReleaseImageRemover::class)->remove($image->fresh());
expect(PressReleaseImage::withTrashed()->find($image->id))->not->toBeNull()
->and(
ArchiveEntry::query()->where('event', ArchiveEvent::ImageRemoved->value)->where('subject_id', $image->id)->exists()
|| ArchivePendingJob::query()->where('event', ArchiveEvent::ImageRemoved->value)->exists()
)->toBeTrue();
})->with(['customer confirmation', 'precheck approval', 'correction release']);
// ---------------------------------------------------------------- B-08 / B-09 Uploader
test('an upload to a rejected release with an old published_at is not marked as ever published', function () {
['pr' => $pr] = pipelineRelease('rejected', ['published_at' => now()->subWeek()]);
$image = app(PressReleaseImageUploader::class)->store(
pipelineFixture('plain.png'),
$pr,
pipelineSubmission(),
ImageUploadActor::form($pr->user, null, null),
);
expect($image->published_ever_at)->toBeNull()
->and($image->published_snapshot_path)->toBeNull();
});
test('an immediately public upload to a published release gets its snapshot', function () {
['pr' => $pr] = pipelineRelease();
$image = app(PressReleaseImageUploader::class)->store(
pipelineFixture('plain.png'),
$pr,
pipelineSubmission(),
ImageUploadActor::form($pr->user, null, null),
);
expect($image->fresh()->published_ever_at)->not->toBeNull()
->and($image->fresh()->published_snapshot_path)->not->toBeNull();
});
test('an admin who owns the release follows the customer status rule in the manager', function () {
/** @var TestCase $this */
['pr' => $pr] = pipelineRelease();
$admin = pipelineAdmin();
$pr->forceFill(['user_id' => $admin->id])->save();
$this->actingAs($admin);
Livewire::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)
->set('newRightsConfirmed', true)
->call('saveImage')
->assertHasErrors(['newImage']);
expect($pr->images()->count())->toBe(0);
});
// ---------------------------------------------------------------- B-10 Bestätigungslink
test('a later login link does not invalidate the image confirmation link', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
app(CustomerImageConfirmation::class)->request($image);
$link = MagicLink::query()->where('user_id', $owner->id)->sole();
expect($link->purpose)->toBe(MagicLink::PURPOSE_IMAGE_CONFIRMATION)
->and($link->expires_at->greaterThan(now()->addHours(71)))->toBeTrue();
app(MagicLinkGenerator::class)->createLoginLink($owner);
expect($link->fresh()->consumed_at)->toBeNull();
// Einlösbar wie ein Login-Link: Token aus der Mail rekonstruieren geht nicht,
// deshalb einen zweiten Bestätigungslink erzeugen und den ersten prüfen.
$generated = app(MagicLinkGenerator::class)->createImageConfirmationLink($owner, $image->id, ['press_release_id' => $pr->id, 'company_id' => $pr->company_id], null, 60);
expect($link->fresh()->consumed_at)->not->toBeNull();
$this->get(route('magic-links.consume', ['token' => $generated['plain_token']]))
->assertRedirect(route('me.press-releases.show', ['id' => $pr->id], absolute: false));
$this->assertAuthenticatedAs($owner);
});
// ---------------------------------------------------------------- B-11 verwaiste Originale
test('the originals sweep lists orphans in a dry run and deletes them only with execute', function () {
/** @var TestCase $this */
['pr' => $pr] = pipelineRelease('draft');
$image = pipelineImage($pr);
$trashed = pipelineImage(pipelineRelease('draft')['pr']);
$trashed->delete();
$disk = Storage::disk('local');
$orphan = 'press-release-originals/'.$pr->id.'/verwaist.jpg';
$fresh = 'press-release-originals/'.$pr->id.'/gerade-geschrieben.jpg';
$disk->put($orphan, 'x');
$disk->put($fresh, 'y');
touch($disk->path($orphan), now()->subDays(3)->getTimestamp());
touch($disk->path($image->original_path), now()->subDays(3)->getTimestamp());
touch($disk->path($trashed->original_path), now()->subDays(3)->getTimestamp());
$this->artisan('press-release-images:sweep-orphan-originals')
->expectsOutputToContain('Trockenlauf: 4 Dateien geprüft, 1 verwaist, 0 entfernt.')
->assertSuccessful();
expect($disk->exists($orphan))->toBeTrue();
$this->artisan('press-release-images:sweep-orphan-originals', ['--execute' => true])
->expectsOutputToContain('Bereinigung: 4 Dateien geprüft, 1 verwaist, 1 entfernt.')
->assertSuccessful();
expect($disk->exists($orphan))->toBeFalse()
->and($disk->exists($fresh))->toBeTrue()
->and($disk->exists($image->original_path))->toBeTrue()
->and($disk->exists($trashed->original_path))->toBeTrue();
});
test('the originals sweep deletes nothing while the archive sweep is paused after a restore', function () {
/** @var TestCase $this */
['pr' => $pr] = pipelineRelease('draft');
$disk = Storage::disk('local');
$orphan = 'press-release-originals/'.$pr->id.'/verwaist.jpg';
$disk->put($orphan, 'x');
touch($disk->path($orphan), now()->subDays(3)->getTimestamp());
// Nach einem DB-Restore fehlen Zeilen, deren Originale noch Beweis sind —
// dieselbe Pause wie für archive:sweep-orphans (A-03) gilt auch hier.
app(EvidenceArchive::class)->pauseSweep();
$this->artisan('press-release-images:sweep-orphan-originals', ['--execute' => true])
->expectsOutputToContain('ausgesetzt')
->assertSuccessful();
expect($disk->exists($orphan))->toBeTrue();
});
// ---------------------------------------------------------------- B-12 Verarbeitungsfehler
test('a processing error in the image service becomes a form error instead of a server error', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
$this->actingAs($owner);
$this->instance(ImageService::class, Mockery::mock(ImageService::class, function (MockInterface $mock): void {
$mock->shouldReceive('storePressReleaseImage')->andThrow(new RuntimeException('Das Bild konnte nicht verarbeitet werden (Titelbild-Variante fehlt).'));
$mock->shouldReceive('deletePressReleaseImage')->andReturnNull();
}));
Livewire::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)
->set('newRightsConfirmed', true)
->call('saveImage')
->assertHasErrors(['newImage']);
expect($pr->images()->count())->toBe(0);
});
// ---------------------------------------------------------------- OPT-5
test('the precheck preparer reads only the first frame and returns a flat jpeg', function () {
$result = app(PrecheckImagePreparer::class)->prepare(base_path('tests/Fixtures/images/webp-capture.webp'));
expect(substr($result['bytes'], 0, 2))->toBe("\xFF\xD8")
->and($result['width'])->toBeGreaterThan(0);
});
// ---------------------------------------------------------------- OPT-3
test('the provenance inspector still finds xmp in png and webp while jpeg is only pinged', function () {
$source = new Imagick(base_path('tests/Fixtures/images/xmp-ai-generated.jpg'));
$xmp = $source->getImageProfiles('xmp', true)['xmp'];
$source->clear();
$png = new Imagick;
$png->newImage(40, 30, 'red');
$png->setImageFormat('png');
$png->setImageProfile('xmp', $xmp);
$path = tempnam(sys_get_temp_dir(), 'pk-xmp-').'.png';
$png->writeImage($path);
$png->clear();
$inspector = new ImageProvenanceInspector;
expect($inspector->inspect($path)->suggestedAiDeclaration()?->value)->toBe('ai_generated')
->and($inspector->inspect(base_path('tests/Fixtures/images/webp-capture.webp'))->suggestedAiDeclaration()?->value)->toBe('original')
->and($inspector->inspect(base_path('tests/Fixtures/images/xmp-ai-generated.jpg'))->suggestedAiDeclaration()?->value)->toBe('ai_generated');
@unlink($path);
});
// ---------------------------------------------------------------- Remover über Archiv-Aufträge
test('removing a published image without a working archive leaves an open job and keeps the evidence until the retry', function () {
['pr' => $pr] = pipelineRelease();
$image = pipelineImage($pr, attributes: ['published_ever_at' => now()->subHour()]);
config()->set('archive.key', null);
app(PressReleaseImageRemover::class)->remove($image, pipelineAdmin());
$job = ArchivePendingJob::query()->sole();
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
->and($job->event)->toBe(ArchiveEvent::ImageRemoved->value)
->and($job->subject_id)->toBe($image->id)
->and(ArchiveEntry::query()->count())->toBe(0)
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue()
->and($pr->fresh()->archive_pending_at)->not->toBeNull();
config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32)));
$result = app(PendingArchiveRetry::class)->retry($job);
expect($result['recorded'])->toBe(1)
->and(ArchiveEntry::query()->where('event', ArchiveEvent::ImageRemoved->value)->where('subject_id', $image->id)->exists())->toBeTrue()
->and(ArchivePendingJob::query()->count())->toBe(0)
->and(Storage::disk('local')->exists($image->original_path))->toBeFalse()
->and($pr->fresh()->archive_pending_at)->toBeNull();
});
test('a failing archive write while removing a published image keeps the job open instead of failing the removal', function () {
['pr' => $pr] = pipelineRelease();
$image = pipelineImage($pr, attributes: ['published_ever_at' => now()->subHour()]);
FailingDisk::install('archive', ['put' => fn () => false]);
app(PressReleaseImageRemover::class)->remove($image);
FailingDisk::restore('archive');
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
->and(ArchivePendingJob::query()->sole()->attempts)->toBe(1)
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue();
});