legal/web: Zustimmungsgate je Dokumentversion (aus bis AGB 0.7), OG-Variante mit eingebranntem Bildnachweis, Nachweis in Teasern (Bildkennzeichnung Blöcke G und H)

- legal_document_acceptances (insert-only), LegalAcceptanceGate, Pflicht-Schalter im Einreichungs-Modal über den gemeinsamen Trait; API: GET/POST /api/v1/legal/acceptances, submit antwortet 428 mit den geforderten Fassungen; LEGAL_ACCEPTANCE_GATE
- ImageService: og-Variante (1200×630) mit halbtransparentem Balken und Inter SemiBold, regenerateOgVariant() für Korrekturen und Bestand; Fokuspunkt/Letterbox getestet
- Uploader brennt den Nachweis beim Upload ein, ImageCorrectionService baut die OG-Variante nach einer Korrektur neu; Artikelseite nutzt og für og:image
- Teaser: Nachweis in alt/title (Feed, Hero, Kategorie, Newsroom), Unterschrift am Hero-Bild; PressRelease::cardImageCredit()

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 10:12:17 +00:00
parent 0240c143c8
commit 62723b551a
27 changed files with 866 additions and 15 deletions

View file

@ -0,0 +1,139 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Services\Image\ImageService;
use App\Services\PressRelease\Images\ImageCorrectionService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* Bildkennzeichnung Block H: OG-Variante mit eingebranntem Nachweis,
* Neuaufbau nach Korrektur, Fokuspunkt/Letterbox, Nachweis in Teasern.
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Storage::fake('local');
Storage::fake('public');
});
function ogPixel(string $path, int $x, int $y): array
{
$img = imagecreatefromjpeg(Storage::disk('local')->path($path));
$rgb = imagecolorat($img, $x, $y);
imagedestroy($img);
return [($rgb >> 16) & 0xFF, ($rgb >> 8) & 0xFF, $rgb & 0xFF];
}
test('the og variant carries a darker caption bar at the bottom while the rest stays untouched', function () {
$upload = UploadedFile::fake()->image('hell.jpg', 2400, 1260)->size(200);
// Ein helles Bild, damit der Balken messbar ist.
$white = imagecreatetruecolor(2400, 1260);
imagefilledrectangle($white, 0, 0, 2400, 1260, imagecolorallocate($white, 250, 250, 250));
imagejpeg($white, $upload->getRealPath(), 95);
imagedestroy($white);
$stored = (new ImageService)->storePressReleaseImage($upload, 7, 'local', ogCaption: 'Foto: Anna Beispiel · KI-generiert');
expect($stored['variants'])->toHaveKey('og');
[$w, $h] = getimagesize(Storage::disk('local')->path($stored['variants']['og']));
expect($w)->toBe(1200)->and($h)->toBe(630);
$top = ogPixel($stored['variants']['og'], 600, 40);
$bar = ogPixel($stored['variants']['og'], 1100, 615);
expect($top[0])->toBeGreaterThan(235)
->and($bar[0])->toBeLessThan(200);
// Cover-Variante ohne Balken.
$coverBottom = ogPixel($stored['variants']['cover'], 1200, 570);
expect($coverBottom[0])->toBeGreaterThan(235);
});
test('the focus point moves the crop and no_crop keeps the whole image', function () {
$upload = UploadedFile::fake()->image('split.jpg', 2000, 1000)->size(200);
// Linke Hälfte rot, rechte Hälfte blau.
$img = imagecreatetruecolor(2000, 1000);
imagefilledrectangle($img, 0, 0, 999, 1000, imagecolorallocate($img, 220, 30, 30));
imagefilledrectangle($img, 1000, 0, 2000, 1000, imagecolorallocate($img, 30, 30, 220));
imagejpeg($img, $upload->getRealPath(), 95);
imagedestroy($img);
$service = new ImageService;
// thumb 320×240 aus 2000×1000: Ausschnitt 1333 px breit. Fokus links →
// Ausschnitt 0–1333 (rot bis Pixel 240 der Miniatur), Fokus rechts →
// 667–2000 (blau ab Pixel 80).
$left = $service->storePressReleaseImage($upload, 1, 'local', focus: ['x' => 0, 'y' => 50]);
$leftPixel = ogPixel($left['variants']['thumb'], 100, 120);
expect($leftPixel[0])->toBeGreaterThan($leftPixel[2]);
$upload2 = UploadedFile::fake()->image('split2.jpg', 2000, 1000)->size(200);
copy($upload->getRealPath(), $upload2->getRealPath());
$right = $service->storePressReleaseImage($upload2, 2, 'local', focus: ['x' => 100, 'y' => 50]);
$rightPixel = ogPixel($right['variants']['thumb'], 100, 120);
expect($rightPixel[2])->toBeGreaterThan($rightPixel[0]);
$upload3 = UploadedFile::fake()->image('split3.jpg', 2000, 1000)->size(200);
copy($upload->getRealPath(), $upload3->getRealPath());
$boxed = $service->storePressReleaseImage($upload3, 3, 'local', noCrop: true);
[$w, $h] = getimagesize(Storage::disk('local')->path($boxed['variants']['cover']));
// 2000×1000 im 1280×580-Rahmen: Bild 1160×580, Ränder links/rechts je 60 px.
$edge = ogPixel($boxed['variants']['cover'], 10, 290);
expect($w)->toBe(1280)->and($h)->toBe(580)
->and($edge[0])->toBeGreaterThan(230)->and(abs($edge[0] - $edge[2]))->toBeLessThan(15);
});
test('a credit correction regenerates the og variant and the detail page uses it', function () {
/** @var TestCase $this */
$category = Category::factory()->create(['portal' => Portal::Presseecho]);
$category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie-og']);
$company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG', 'is_active' => true]);
$pr = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)->create([
'slug' => 'og-test', 'published_ever_at' => now()->subDay(),
]);
$upload = UploadedFile::fake()->image('foto.jpg', 1600, 900)->size(100);
$stored = (new ImageService)->storePressReleaseImage($upload, $pr->id, 'local', ogCaption: 'Foto: Alt');
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create([
'disk' => 'local', 'path' => $stored['path'], 'variants' => $stored['variants'], 'copyright' => 'Alt', 'published_ever_at' => now()->subDay(),
]);
$before = md5((string) Storage::disk('local')->get($stored['variants']['og']));
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
app(ImageCorrectionService::class)->correctCredit($image, $admin, 'Neuer sehr langer Nachweis der gekürzt werden muss weil er nicht in den Balken passt Anna Beispiel Musterfirma GmbH', 'Einwand');
$after = md5((string) Storage::disk('local')->get($image->fresh()->variants['og']));
expect($after)->not->toBe($before);
$this->get('https://presseecho.test/de/press-release/og-test')
->assertSuccessful()
->assertSee('<meta property="og:image" content="'.route('media.press-release-images.show', ['image' => $image->id, 'variant' => 'og']).'">', false);
});
test('teasers carry the credit in alt and title and the hero shows a caption', function () {
/** @var TestCase $this */
$category = Category::factory()->create(['portal' => Portal::Presseecho]);
$category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie-teaser']);
$company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG', 'is_active' => true]);
$pr = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)->create([
'title' => 'Teaser-Meldung', 'published_at' => now()->subHour(), 'published_ever_at' => now()->subHour(),
]);
PressReleaseImage::factory()->for($pr)->rulesV2()->create(['copyright' => 'Anna Beispiel', 'ai_declaration' => 'ai_generated']);
expect($pr->fresh()->load('images', 'company')->cardImageCredit())->toBe('Foto: Anna Beispiel · KI-generiert');
$this->get('https://presseecho.test/de/category/energie-teaser')
->assertSuccessful()
->assertSee('alt="Teaser-Meldung · Foto: Anna Beispiel · KI-generiert"', false)
->assertSee('data-image-credit-teaser', false);
});