web/admin/pdf: gemeinsame Bildnachweis-Auflösung, Detailfenster am Artikelbild, Bestätigungsstand in Kunden- und Adminansicht, Wortwahl „automatisierte Prüfung" (Bildkennzeichnung Block D)

- App\Support\ImageCredit: Foto/Quelle-Zeile, KI-Hinweis mit Herkunft, Verantwortlich-Zeile nur mit Protokoll, sichere Links; PDF-Zeilen mit Bestätigungskennung
- Artikelseite: Overlay (eigenes i-Zeichen, Alpine ohne Plugin, Bottom-Sheet auf Mobil) mit Nachweis, Lizenz, Quelle, KI-Hinweis, Verantwortlich-Zeile, „Bild melden"
- Richtlinien 0.2: Prüfung als automatisiert beschrieben, Abschnitt „Bilder" (Decision-Update 3.5); Taglines, Suche, Newsrooms, Startseiten-CTA, Mails ohne „qualitätsgeprüft"
- Cache-Invalidierung bei Bildwechsel an veröffentlichten Meldungen; PHPStan-Baseline für das Resource bereinigt

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 09:44:34 +00:00
parent 65ac72ce05
commit 2124ffd1f2
26 changed files with 578 additions and 199 deletions

View file

@ -76,9 +76,13 @@ test('an outdated text version is rejected with the current key in the message',
test('a valid upload stores the confirmation with the token and returns the new fields', function () {
/** @var TestCase $this */
['pressRelease' => $pressRelease, 'user' => $user] = rightsV2ApiDraft();
// Echter Bearer-Token statt Sanctum::actingAs, damit die Token-ID im
// Protokoll landet (TransientToken hat keinen Datensatz).
$user = User::factory()->create();
$pressRelease = PressRelease::factory()->create(['user_id' => $user->id, 'status' => PressReleaseStatus::Draft->value]);
$plainToken = $user->createToken('connektar', ['press-releases:read', 'press-release-images:write'])->plainTextToken;
$response = $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [
$response = $this->withToken($plainToken)->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [
'image' => rightsV2ApiFixture('xmp-ai-generated.jpg'),
'ai_declaration' => 'ai_generated',
'copyright' => 'KI-Bild / Musterfirma',
@ -110,7 +114,7 @@ test('a valid upload stores the confirmation with the token and returns the new
->and($image->focus_y)->toBe(80)
->and($confirmation->channel)->toBe(ConfirmationChannel::Api)
->and($confirmation->user_id)->toBe($user->id)
->and($confirmation->personal_access_token_id)->not->toBeNull()
->and($confirmation->personal_access_token_id)->toBe($user->tokens()->sole()->getKey())
->and($confirmation->user_agent)->toBe('ConnektarBot/2.0')
->and($response->json('data.urls.original'))->toBe(route('media.press-release-images.show', ['image' => $image->id]))
->and($response->json('data.path'))->not->toContain('press-release-originals');

View file

@ -0,0 +1,141 @@
<?php
use App\Enums\ConfirmationChannel;
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\ImageRightsConfirmation;
use App\Models\LegalDocument;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Support\ImageCredit;
use Database\Seeders\LegalDocumentSeeder;
use Tests\TestCase;
/**
* Bildkennzeichnung Block D: eine Auflösung für Nachweiszeile, Overlay,
* Kunden-/Adminansicht und PDF; Verantwortlich-Zeile nur mit Protokoll.
*/
function creditPublishedRelease(): array
{
$category = Category::factory()->create(['portal' => Portal::Presseecho]);
$category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie']);
$company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG', 'is_active' => true]);
$release = PressRelease::factory()
->published()
->forPortal(Portal::Presseecho)
->for($category)
->for($company)
->create(['slug' => 'bild-nachweis-test', 'published_at' => now()->subDay()]);
return compact('category', 'company', 'release');
}
function creditConfirmationFor(PressReleaseImage $image, string $channel = 'form'): ImageRightsConfirmation
{
return ImageRightsConfirmation::query()->create([
'subject_type' => $image->getMorphClass(),
'subject_id' => $image->getKey(),
'press_release_id' => $image->press_release_id,
'company_id' => $image->pressRelease->company_id,
'user_id' => $image->pressRelease->user_id,
'channel' => ConfirmationChannel::from($channel),
'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()->setDate(2026, 9, 22)->setTime(10, 0),
'created_at' => now(),
]);
}
test('a formular v2 image renders credit line, ai hint with source and the responsible line', function () {
/** @var TestCase $this */
['release' => $release, 'company' => $company] = creditPublishedRelease();
$image = PressReleaseImage::factory()->for($release)->rulesV2()->create([
'copyright' => 'Anna Beispiel / Beispiel AG',
'ai_declaration' => 'ai_edited',
'ai_declaration_user' => 'ai_edited',
'ai_declaration_source' => 'customer',
'license_url' => 'https://example.com/lizenz',
'source_url' => 'javascript:alert(1)',
]);
creditConfirmationFor($image);
$credit = ImageCredit::for($image->fresh(), $company);
expect($credit->line())->toBe('Foto: Anna Beispiel / Beispiel AG · KI-bearbeitet')
->and($credit->responsibleLine)->toBe('Bild und Angaben: Beispiel AG, eingereicht am 22. September 2026')
->and($credit->aiSourceLabel)->toBe('Angabe des Einreichers')
->and($credit->licenseUrl)->toBe('https://example.com/lizenz')
->and($credit->sourceUrl)->toBeNull()
->and($credit->pdfLines())->toContain('Rechte bestätigt am 22.09.2026 10:00 (img-confirm-v2, AGB 0.6)');
$this->get('https://presseecho.test/de/press-release/bild-nachweis-test')
->assertSuccessful()
->assertSeeText('Foto: Anna Beispiel / Beispiel AG · KI-bearbeitet')
->assertSee('data-image-credit', false)
->assertSeeText('Dieses Bild wurde mit KI bearbeitet.')
->assertSeeText('Kennzeichnung: Angabe des Einreichers')
->assertSeeText('Bild und Angaben: Beispiel AG, eingereicht am 22. September 2026')
->assertSeeText('Bild melden')
->assertDontSee('javascript:alert', false);
});
test('a legacy image without credit falls back to the company as source and shows no responsible line', function () {
/** @var TestCase $this */
['release' => $release, 'company' => $company] = creditPublishedRelease();
$image = PressReleaseImage::factory()->for($release)->legacy()->create(['is_ai_generated' => true]);
$credit = ImageCredit::for($image, $company);
expect($credit->line())->toBe('Quelle: Beispiel AG · KI-generiert')
->and($credit->responsibleLine)->toBeNull()
->and($credit->aiSourceLabel)->toBeNull()
->and($credit->isLegacy)->toBeTrue();
$this->get('https://presseecho.test/de/press-release/bild-nachweis-test')
->assertSuccessful()
->assertSeeText('Quelle: Beispiel AG · KI-generiert')
->assertDontSeeText('Bild und Angaben');
});
test('a legacy image with author but without copyright uses the author and the old fields stay in the pdf lines', function () {
['release' => $release, 'company' => $company] = creditPublishedRelease();
$image = PressReleaseImage::factory()->for($release)->legacy()->create([
'author' => 'Max Mustermann',
'license_type' => 'own',
]);
$credit = ImageCredit::for($image, $company);
expect($credit->line())->toBe('Foto: Max Mustermann')
->and($credit->pdfLines())->toBe(['Bildnachweis: Max Mustermann']);
});
test('an image without any detail renders no line and no overlay', function () {
/** @var TestCase $this */
['release' => $release] = creditPublishedRelease();
PressReleaseImage::factory()->for($release)->legacy()->create();
$release->company()->update(['name' => '']);
$credit = ImageCredit::for($release->images()->first(), $release->company()->first());
expect($credit->line())->toBeNull()
->and($credit->hasDetails())->toBeFalse();
});
test('the guidelines page describes the automated check and the image rules under version 0.2', function () {
/** @var TestCase $this */
$this->seed(LegalDocumentSeeder::class);
expect(LegalDocument::current(LegalDocument::TYPE_RICHTLINIEN)?->version)->toBe('0.2');
$this->get('https://presseecho.test/de/richtlinien')
->assertSuccessful()
->assertSeeText('Jede Mitteilung durchläuft vor der Veröffentlichung eine automatisierte Prüfung.')
->assertSeeText('Zu jedem Bild gehört ein Bildnachweis')
->assertDontSeeText('geprüft, den Richtlinien entsprechend');
});

View file

@ -21,12 +21,13 @@ test('the agb version falls back to the config while the table is empty', functi
expect(LegalDocument::currentAgbVersion())->toBe('9.9');
});
test('the seeder registers all five legal documents idempotently', function () {
test('the seeder registers all legal documents idempotently', function () {
/** @var TestCase $this */
$this->seed(LegalDocumentSeeder::class);
$this->seed(LegalDocumentSeeder::class); // Wiederholung darf nichts duplizieren.
expect(LegalDocument::query()->count())->toBe(5);
expect(LegalDocument::query()->count())->toBe(6);
expect(LegalDocument::current(LegalDocument::TYPE_RICHTLINIEN)?->version)->toBe('0.2');
expect(LegalDocument::currentAgbVersion())->toBe('0.6');
expect(LegalDocument::current(LegalDocument::TYPE_DATENSCHUTZ)?->version)->toBe('0.3');
});

View file

@ -42,7 +42,7 @@ test('businessportal24 homepage renders the editorial shell with real data', fun
->assertSeeText('Im Fokus')
->assertSeeText('Aktive Newsrooms')
->assertSeeText('Veröffentlichen Sie Ihre Pressemitteilung')
->assertSeeText('Ihre Pressemitteilung, qualitätsgeprüft veröffentlicht')
->assertSeeText('Ihre Pressemitteilung, automatisiert geprüft und veröffentlicht')
->assertSeeText('Konto anlegen')
->assertSeeText('Unser Qualitätsstandard')
->assertDontSeeText('redaktionell geprüft')

View file

@ -41,7 +41,7 @@ test('presseecho homepage renders the editorial shell with real data', function
->assertSeeText('Im Fokus')
->assertSeeText('Aktive Newsrooms')
->assertSeeText('Veröffentlichen Sie Ihre Pressemitteilung')
->assertSeeText('Ihre Pressemitteilung, qualitätsgeprüft veröffentlicht')
->assertSeeText('Ihre Pressemitteilung, automatisiert geprüft und veröffentlicht')
->assertSeeText('Konto anlegen')
->assertSeeText('Unser Qualitätsstandard')
->assertDontSeeText('redaktionell geprüft')