presseportale/tests/Feature/Images/OpenGraphAndTeaserCreditTest.php
Kevin Adametz 075d0d931f 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>
2026-09-22 10:12:17 +00:00

139 lines
6.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
});