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 */ 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)); LivewireVolt::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']); LivewireVolt::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); LivewireVolt::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 = LivewireVolt::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); }); })); Sanctum::actingAs($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); LivewireVolt::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(); })); LivewireVolt::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(); });