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>
542 lines
24 KiB
PHP
542 lines
24 KiB
PHP
<?php
|
|
|
|
use App\Enums\AiImageDeclaration;
|
|
use App\Enums\ImagePrecheckCategory;
|
|
use App\Enums\ImagePrecheckOutcome;
|
|
use App\Enums\PressReleaseStatus;
|
|
use App\Models\Category;
|
|
use App\Models\Company;
|
|
use App\Models\KiAudit;
|
|
use App\Models\PressRelease;
|
|
use App\Models\PressReleaseImage;
|
|
use App\Models\User;
|
|
use App\Services\Image\Precheck\ImagePrecheckResult;
|
|
use App\Services\Image\Precheck\ImagePrecheckReviewService;
|
|
use App\Services\Image\Precheck\ImagePrecheckService;
|
|
use App\Services\PressRelease\Images\ImageSubmission;
|
|
use App\Services\PressRelease\Images\ImageUploadActor;
|
|
use App\Services\PressRelease\Images\ImageUploadException;
|
|
use App\Services\PressRelease\Images\PressReleaseImageUploader;
|
|
use Database\Seeders\LegalDocumentSeeder;
|
|
use Database\Seeders\LegalTextSeeder;
|
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
|
use Illuminate\Http\Client\Request;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Livewire\Livewire;
|
|
use Tests\TestCase;
|
|
|
|
beforeEach(function (): void {
|
|
/** @var TestCase $this */
|
|
Storage::fake('local');
|
|
$this->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<string, float|array<string, mixed>> $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<string, float|array<string, mixed>> ...$overridesList
|
|
*/
|
|
function precheckFakeSequence(array ...$overridesList): void
|
|
{
|
|
$sequence = Http::sequence();
|
|
|
|
foreach ($overridesList as $overrides) {
|
|
$sequence->push(precheckResponseBody($overrides), 200);
|
|
}
|
|
|
|
Http::fake(['*' => $sequence]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, float|array<string, mixed>> $overrides
|
|
* @return array<string, mixed>
|
|
*/
|
|
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());
|
|
|
|
Livewire::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]);
|
|
|
|
Livewire::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);
|
|
|
|
Livewire::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]);
|
|
apiActingAs($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);
|
|
});
|