seed(RolesAndPermissionsSeeder::class); $this->seed(LegalDocumentSeeder::class); $this->seed(LegalTextSeeder::class); config()->set('images.rights_v2', true); config()->set('images.precheck.enabled', true); config()->set('images.precheck.stage', 2); config()->set('images.precheck.provider', 'openai'); config()->set('services.openai.api_key', 'test-key'); }); /** * Antwort des Vision-Modells: alle Kategorien ohne Befund, Überschreibungen * als ['stock_watermark' => 0.92] oder ['technical_defect' => ['hit' => true, 'note' => '…']]. * * @param array> $overrides */ function precheckFake(array $overrides = []): void { Http::fake(['*' => Http::response(precheckResponseBody($overrides), 200)]); } /** * Mehrere Antworten nacheinander (ein Http::fake je Test, sonst gewinnt die * erste Registrierung). * * @param array> ...$overridesList */ function precheckFakeSequence(array ...$overridesList): void { $sequence = Http::sequence(); foreach ($overridesList as $overrides) { $sequence->push(precheckResponseBody($overrides), 200); } Http::fake(['*' => $sequence]); } /** * @param array> $overrides * @return array */ function precheckResponseBody(array $overrides = []): array { $categories = []; foreach (ImagePrecheckCategory::cases() as $category) { $entry = ['hit' => false, 'confidence' => 0.05]; if ($category === ImagePrecheckCategory::TechnicalDefect) { $entry['note'] = ''; } $override = $overrides[$category->value] ?? null; if (is_float($override) || is_int($override)) { $entry = ['hit' => $override >= 0.5, 'confidence' => (float) $override] + $entry; } elseif (is_array($override)) { $entry = $override + $entry; } $categories[$category->value] = $entry; } return [ 'choices' => [['message' => ['content' => json_encode($categories)]]], 'usage' => ['total_tokens' => 42], ]; } function precheckResult(array $overrides = []): ImagePrecheckResult { $categories = []; foreach (ImagePrecheckCategory::cases() as $category) { $value = $overrides[$category->value] ?? 0.0; $categories[$category->value] = is_array($value) ? $value : ['hit' => $value >= 0.5, 'confidence' => $value, 'note' => null]; } return ImagePrecheckResult::ok($categories, 'openai', 'test-model', 'abc'); } /** * @return array{owner: User, pr: PressRelease, company: Company} */ function precheckDraft(): array { $owner = User::factory()->create(['is_active' => true]); $owner->assignRole('customer'); $company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG']); $owner->companies()->attach($company->id, ['role' => 'owner']); $pr = PressRelease::factory()->create([ 'user_id' => $owner->id, 'company_id' => $company->id, 'category_id' => Category::factory()->create()->id, 'portal' => $company->portal->value, 'status' => PressReleaseStatus::Draft->value, ]); return compact('owner', 'pr', 'company'); } function precheckUpload(string $name = 'plain.png'): UploadedFile { $source = base_path('tests/Fixtures/images/'.$name); $copy = tempnam(sys_get_temp_dir(), 'pk-precheck-'); copy($source, $copy); return new UploadedFile($copy, $name, mime_content_type($copy), null, true); } function precheckSubmission(array $overrides = []): ImageSubmission { return new ImageSubmission(...array_merge([ 'copyright' => 'Beispiel AG', 'aiDeclaration' => AiImageDeclaration::Original, 'rightsConfirmed' => true, 'legalTextKey' => 'img-confirm-v2', ], $overrides)); } function precheckAdmin(): User { $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); return $admin; } // --------------------------------------------------------------------------- // K.2 Auswertung // --------------------------------------------------------------------------- test('reject categories reject from 0.85 and queue between 0.6 and 0.85', function () { $service = app(ImagePrecheckService::class); expect($service->evaluate(precheckResult(['stock_watermark' => 0.92]), null)->outcome)->toBe(ImagePrecheckOutcome::Reject) ->and($service->evaluate(precheckResult(['stock_watermark' => 0.7]), null)->outcome)->toBe(ImagePrecheckOutcome::Queue) ->and($service->evaluate(precheckResult(['stock_watermark' => 0.4]), null)->outcome)->toBe(ImagePrecheckOutcome::Pass) ->and($service->evaluate(precheckResult(['document_or_id' => 0.9]), null)->outcome)->toBe(ImagePrecheckOutcome::Reject) ->and($service->evaluate(precheckResult(['nudity_or_violence' => 0.86]), null)->rejectionMessage())->toContain('Nacktheit oder Gewalt'); }); test('queue categories queue from 0.6 and hints never block', function () { $service = app(ImagePrecheckService::class); $queued = $service->evaluate(precheckResult(['screenshot_or_collage' => 0.65, 'persons_visible' => 0.9]), null); expect($queued->outcome)->toBe(ImagePrecheckOutcome::Queue) ->and($queued->hints())->toContain('Auf dem Bild sind Personen zu sehen. Mit Ihrer Bestätigung erklären Sie, dass sie mit der Veröffentlichung einverstanden sind.'); $hinted = $service->evaluate(precheckResult(['persons_visible' => 0.95, 'license_plate_readable' => 0.3]), null); expect($hinted->outcome)->toBe(ImagePrecheckOutcome::Hint) ->and($hinted->triggers)->toHaveCount(1); }); test('looks ai generated is only a hint when the customer declared no ai', function () { $service = app(ImagePrecheckService::class); $result = precheckResult(['looks_ai_generated' => 0.8]); expect($service->evaluate($result, AiImageDeclaration::Original)->hints())->toBe(['Bitte prüfen Sie Ihre Angabe zur KI-Kennzeichnung.']) ->and($service->evaluate($result, null)->hints())->toHaveCount(1) ->and($service->evaluate($result, AiImageDeclaration::AiGenerated)->outcome)->toBe(ImagePrecheckOutcome::Pass) ->and($service->evaluate($result, AiImageDeclaration::AiEdited)->outcome)->toBe(ImagePrecheckOutcome::Pass); }); test('stage 1 evaluates only the stock watermark and a downgraded category only queues', function () { $service = app(ImagePrecheckService::class); config()->set('images.precheck.stage', 1); expect($service->evaluate(precheckResult(['document_or_id' => 0.99, 'persons_visible' => 0.99]), null)->outcome)->toBe(ImagePrecheckOutcome::Pass) ->and($service->evaluate(precheckResult(['stock_watermark' => 0.9]), null)->outcome)->toBe(ImagePrecheckOutcome::Reject); config()->set('images.precheck.downgrade_to_queue', ['stock_watermark']); expect($service->evaluate(precheckResult(['stock_watermark' => 0.99]), null)->outcome)->toBe(ImagePrecheckOutcome::Queue); }); test('a technical defect carries its note into the hint and an error result passes through', function () { $service = app(ImagePrecheckService::class); $defect = $service->evaluate(precheckResult(['technical_defect' => ['hit' => true, 'confidence' => 0.9, 'note' => 'Platzhalterbild']]), null); expect($defect->outcome)->toBe(ImagePrecheckOutcome::Hint) ->and($defect->hints()[0])->toContain('Platzhalterbild'); $error = $service->evaluate(ImagePrecheckResult::error('Timeout', 'openai', null, 'abc'), null); expect($error->outcome)->toBe(ImagePrecheckOutcome::Error) ->and($error->summary())->toBe('nicht geprüft'); }); // --------------------------------------------------------------------------- // K.1 Treiber // --------------------------------------------------------------------------- test('the openai driver sends a downscaled metadata free jpeg as data uri and parses the schema', function () { precheckFake(['stock_watermark' => 0.91]); $upload = precheckUpload('camera-iptc.jpg'); $result = app(ImagePrecheckService::class)->run($upload->getRealPath(), 'sha'); expect($result)->not->toBeNull() ->and($result->isError())->toBeFalse() ->and($result->provider)->toBe('openai') ->and($result->fileSha256)->toBe('sha') ->and($result->confidence(ImagePrecheckCategory::StockWatermark))->toBe(0.91) ->and($result->raw['sent_size'][0])->toBeLessThanOrEqual(1024); Http::assertSent(function (Request $request): bool { $payload = $request->data(); $image = $payload['messages'][1]['content'][1]['image_url']['url'] ?? ''; if (! str_starts_with($image, 'data:image/jpeg;base64,')) { return false; } $jpeg = base64_decode(substr($image, strlen('data:image/jpeg;base64,')), true); $imagick = new Imagick; $imagick->readImageBlob((string) $jpeg); // Metadaten gestrippt: das Kamera-Fixture trägt im Original ein IPTC-Profil. $profiles = $imagick->getImageProfiles('*', false); return ($payload['response_format']['type'] ?? null) === 'json_object' && ! in_array('iptc', $profiles, true) && ! in_array('exif', $profiles, true) && str_contains((string) $payload['messages'][0]['content'], 'Identifiziere keine Personen'); }); }); test('a failing provider yields an error result and never blocks', function () { Http::fake(['*' => Http::response('error', 500)]); $upload = precheckUpload(); $result = app(ImagePrecheckService::class)->run($upload->getRealPath(), 'sha'); expect($result->isError())->toBeTrue() ->and($result->errorMessage)->toContain('HTTP 500'); }); test('the check is skipped entirely while disabled', function () { config()->set('images.precheck.enabled', false); Http::fake(); ['pr' => $pr] = precheckDraft(); $image = app(PressReleaseImageUploader::class)->store(precheckUpload(), $pr, precheckSubmission(), ImageUploadActor::form($pr->user, '203.0.113.9', 'Pest')); Http::assertNothingSent(); expect($image->precheck)->toBeNull() ->and($image->precheck_outcome)->toBeNull() ->and(KiAudit::query()->where('type', KiAudit::TYPE_IMAGE_PRECHECK)->count())->toBe(0); }); // --------------------------------------------------------------------------- // K.3 Uploader // --------------------------------------------------------------------------- test('a rejected upload stores nothing and leaves an audit entry without the file', function () { precheckFake(['stock_watermark' => 0.95]); ['pr' => $pr] = precheckDraft(); expect(fn () => app(PressReleaseImageUploader::class)->store(precheckUpload(), $pr, precheckSubmission(), ImageUploadActor::form($pr->user, '203.0.113.9', 'Pest'))) ->toThrow(ImageUploadException::class, 'Wasserzeichen einer Bildagentur'); expect($pr->images()->count())->toBe(0) ->and(Storage::disk('local')->allFiles())->toBe([]); $audit = KiAudit::query()->where('type', KiAudit::TYPE_IMAGE_PRECHECK)->sole(); expect($audit->press_release_id)->toBe($pr->id) ->and($audit->result)->toBe('reject') ->and($audit->raw_response['image_id'])->toBeNull() ->and($audit->raw_response['categories']['stock_watermark']['confidence'])->toBe(0.95) ->and($audit->reason)->toContain('stock_watermark 0.95'); }); test('a queued upload is stored withheld with review flag, audit and snapshot in the confirmation', function () { precheckFake(['screenshot_or_collage' => 0.7, 'persons_visible' => 0.9]); ['pr' => $pr] = precheckDraft(); $image = app(PressReleaseImageUploader::class)->store(precheckUpload(), $pr, precheckSubmission(), ImageUploadActor::form($pr->user, '203.0.113.9', 'Pest')); expect($image->precheck_outcome)->toBe(ImagePrecheckOutcome::Queue) ->and($image->awaitsPrecheckReview())->toBeTrue() ->and($image->isWithheld())->toBeTrue() ->and($image->ai_declaration)->toBe(AiImageDeclaration::Original) ->and($image->precheck['evaluation']['triggers'][0]['category'])->toBe('screenshot_or_collage') ->and($image->latestRightsConfirmation->precheck_snapshot['evaluation']['outcome'])->toBe('queue') ->and(PressReleaseImage::query()->publiclyVisible()->whereKey($image->id)->exists())->toBeFalse(); $audit = KiAudit::query()->where('type', KiAudit::TYPE_IMAGE_PRECHECK)->sole(); expect($audit->raw_response['image_id'])->toBe($image->id) ->and($audit->result)->toBe('queue'); }); test('looks ai generated never changes the declaration and a pass stores the result', function () { precheckFake(['looks_ai_generated' => 0.9]); ['pr' => $pr] = precheckDraft(); $image = app(PressReleaseImageUploader::class)->store(precheckUpload(), $pr, precheckSubmission(['aiDeclaration' => AiImageDeclaration::Original]), ImageUploadActor::form($pr->user, '203.0.113.9', 'Pest')); expect($image->ai_declaration)->toBe(AiImageDeclaration::Original) ->and($image->ai_declaration_locked)->toBeFalse() ->and($image->precheck_outcome)->toBe(ImagePrecheckOutcome::Hint) ->and($image->awaitsPrecheckReview())->toBeFalse() ->and($image->isWithheld())->toBeFalse(); }); test('a preflight result is reused when the hash matches and recomputed otherwise', function () { precheckFake(); ['pr' => $pr] = precheckDraft(); $upload = precheckUpload(); $sha = hash_file('sha256', $upload->getRealPath()); $matching = precheckResult(['persons_visible' => 0.9])->withFileSha256($sha); $image = app(PressReleaseImageUploader::class)->store($upload, $pr, precheckSubmission(), ImageUploadActor::form($pr->user, '203.0.113.9', 'Pest'), precheck: $matching); Http::assertNothingSent(); expect($image->precheck_outcome)->toBe(ImagePrecheckOutcome::Hint); $pr->images()->forceDelete(); $stale = precheckResult(['stock_watermark' => 0.99])->withFileSha256('other'); $second = app(PressReleaseImageUploader::class)->store(precheckUpload(), $pr, precheckSubmission(), ImageUploadActor::form($pr->user, '203.0.113.9', 'Pest'), precheck: $stale); Http::assertSentCount(1); expect($second->precheck_outcome)->toBe(ImagePrecheckOutcome::Pass); }); test('a provider failure is stored as error outcome and the image goes through', function () { Http::fake(['*' => Http::response('error', 503)]); ['pr' => $pr] = precheckDraft(); $image = app(PressReleaseImageUploader::class)->store(precheckUpload(), $pr, precheckSubmission(), ImageUploadActor::form($pr->user, '203.0.113.9', 'Pest')); expect($image->precheck_outcome)->toBe(ImagePrecheckOutcome::Error) ->and($image->precheck['status'])->toBe('error') ->and($image->isWithheld())->toBeFalse() ->and(KiAudit::query()->where('type', KiAudit::TYPE_IMAGE_PRECHECK)->value('result'))->toBe('error'); }); // --------------------------------------------------------------------------- // K.4 Sichtung // --------------------------------------------------------------------------- test('approval releases the image and rejection removes it, both with audit', function () { precheckFake(['third_party_logo_prominent' => 0.8]); ['pr' => $pr] = precheckDraft(); $admin = precheckAdmin(); $reviews = app(ImagePrecheckReviewService::class); $image = app(PressReleaseImageUploader::class)->store(precheckUpload(), $pr, precheckSubmission(), ImageUploadActor::form($pr->user, '203.0.113.9', 'Pest')); $reviews->approve($image, $admin, 'Eigenes Logo des Kunden.'); $image->refresh(); expect($image->awaitsPrecheckReview())->toBeFalse() ->and($image->isWithheld())->toBeFalse() ->and($image->precheck_reviewed_by)->toBe($admin->id) ->and($image->precheck_review_note)->toBe('Eigenes Logo des Kunden.') ->and(KiAudit::query()->where('type', KiAudit::TYPE_IMAGE_PRECHECK_REVIEW)->value('result'))->toBe('approved'); $pr->images()->forceDelete(); $second = app(PressReleaseImageUploader::class)->store(precheckUpload(), $pr, precheckSubmission(), ImageUploadActor::form($pr->user, '203.0.113.9', 'Pest')); $reviews->reject($second, $admin, 'Fremdes Logo, keine Freigabe.'); expect(PressReleaseImage::withTrashed()->whereKey($second->id)->exists())->toBeFalse() ->and(KiAudit::query()->where('type', KiAudit::TYPE_IMAGE_PRECHECK_REVIEW)->where('result', 'rejected')->value('reason'))->toBe('Fremdes Logo, keine Freigabe.'); }); test('the admin page shows the review panel and approves with a note', function () { /** @var TestCase $this */ precheckFake(['license_plate_readable' => 0.75]); ['pr' => $pr] = precheckDraft(); $image = app(PressReleaseImageUploader::class)->store(precheckUpload(), $pr, precheckSubmission(), ImageUploadActor::form($pr->user, '203.0.113.9', 'Pest')); $this->actingAs(precheckAdmin()); LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id]) ->assertSee('Bild-Sichtung: Vorab-Check hat etwas gefunden') ->assertSee('Kfz-Kennzeichen lesbar') ->call('approvePrecheck') ->assertHasErrors(['precheckNote']) ->set('precheckNote', 'Kennzeichen gehört zum Firmenwagen.') ->call('approvePrecheck') ->assertHasNoErrors() ->assertDontSee('Bild-Sichtung: Vorab-Check hat etwas gefunden'); expect($image->fresh()->isWithheld())->toBeFalse(); }); // --------------------------------------------------------------------------- // K.5 Formular und API // --------------------------------------------------------------------------- test('the form shows the rejection under the file field and hints after preflight', function () { /** @var TestCase $this */ ['owner' => $owner, 'pr' => $pr] = precheckDraft(); $this->actingAs($owner); precheckFakeSequence(['stock_watermark' => 0.9], ['persons_visible' => 0.9, 'looks_ai_generated' => 0.8]); LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) ->call('openUploadForm') ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) ->assertHasErrors(['newImage']) ->assertSee('Wasserzeichen einer Bildagentur') ->assertSet('newImage', null); LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) ->call('openUploadForm') ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) ->assertHasNoErrors() ->assertSee('Auf dem Bild sind Personen zu sehen.') ->assertSee('Bitte prüfen Sie Ihre Angabe zur KI-Kennzeichnung.') ->set('newAiDeclaration', AiImageDeclaration::AiGenerated->value) ->assertDontSee('Bitte prüfen Sie Ihre Angabe zur KI-Kennzeichnung.') ->set('newCopyright', 'Beispiel AG') ->set('newRightsConfirmed', true) ->call('saveImage') ->assertHasNoErrors(); // Die Vorprüfung wurde beim Speichern nicht wiederholt (Hash-Bindung): // eine Anfrage je Dateiauswahl, keine beim Speichern. Http::assertSentCount(2); $image = $pr->images()->sole(); // Personen-Hinweis bleibt als Ausgang `hint`; die Kennzeichnung ist die Kundenangabe. expect($image->precheck_outcome)->toBe(ImagePrecheckOutcome::Hint) ->and($image->ai_declaration)->toBe(AiImageDeclaration::AiGenerated) ->and($image->isWithheld())->toBeFalse(); }); test('the api answers 422 with the reason on rejection and flags queued images', function () { /** @var TestCase $this */ $user = User::factory()->create(); $pr = PressRelease::factory()->create(['user_id' => $user->id, 'status' => PressReleaseStatus::Draft->value]); Sanctum::actingAs($user, ['press-releases:read', 'press-release-images:write']); $payload = [ 'copyright' => 'Beispiel AG', 'ai_declaration' => AiImageDeclaration::Original->value, 'rights_confirmed' => true, 'confirm_text_version' => 'img-confirm-v2', ]; precheckFakeSequence(['document_or_id' => 0.9], ['screenshot_or_collage' => 0.7]); $this->postJson("/api/v1/press-releases/{$pr->id}/images", $payload + ['image' => UploadedFile::fake()->image('ausweis.jpg', 1000, 700)]) ->assertUnprocessable() ->assertJsonPath('errors.image.0', fn (string $m): bool => str_contains($m, 'Dokument oder einen Ausweis')); $this->postJson("/api/v1/press-releases/{$pr->id}/images", $payload + ['image' => UploadedFile::fake()->image('screen.jpg', 1000, 700)]) ->assertCreated() ->assertJsonPath('data.warnings', fn (array $w): bool => in_array('image_under_review', $w, true)); }); test('a formally unusable vision answer becomes an error result, never a pass (N20)', function (array $patch) { $categories = json_decode((string) precheckResponseBody()['choices'][0]['message']['content'], true); if (array_key_exists('*', $patch)) { $categories = array_fill_keys(array_keys($categories), $patch['*']); } else { $categories = array_replace($categories, $patch); } Http::fake(['*' => Http::response(['choices' => [['message' => ['content' => json_encode($categories)]]]], 200)]); $upload = precheckUpload(); $service = app(ImagePrecheckService::class); $result = $service->run($upload->getRealPath(), 'sha'); expect($result)->not->toBeNull() ->and($result->isError())->toBeTrue() ->and($result->errorMessage)->toContain('Schema') ->and($result->categories)->toBe([]) ->and($service->evaluate($result, null)->outcome)->toBe(ImagePrecheckOutcome::Error); })->with([ 'alle Kategorien null' => [['*' => null]], 'alle Kategorien leere Objekte' => [['*' => []]], 'hit als String "false"' => [['stock_watermark' => ['hit' => 'false', 'confidence' => 0.95]]], 'hit fehlt' => [['nudity_or_violence' => ['confidence' => 0.1]]], 'Konfidenz als Text' => [['document_or_id' => ['hit' => true, 'confidence' => 'hoch']]], 'Konfidenz über 1' => [['stock_watermark' => ['hit' => true, 'confidence' => 1.7]]], 'Konfidenz unter 0' => [['persons_visible' => ['hit' => false, 'confidence' => -0.1]]], 'Notiz als Objekt' => [['technical_defect' => ['hit' => true, 'confidence' => 0.9, 'note' => ['x']]]], ]); test('a stored precheck result counts only a real true as a hit (N20)', function () { $result = ImagePrecheckResult::fromArray([ 'status' => 'ok', 'provider' => 'openai', 'checked_at' => now()->toIso8601String(), 'categories' => [ 'stock_watermark' => ['hit' => 'false', 'confidence' => 0.95], 'persons_visible' => ['hit' => true, 'confidence' => 0.8], ], ]); expect($result)->not->toBeNull() ->and($result->confidence(ImagePrecheckCategory::StockWatermark))->toBe(0.0) ->and($result->confidence(ImagePrecheckCategory::PersonsVisible))->toBe(0.8); });