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>
846 lines
38 KiB
PHP
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();
|
|
});
|