- 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>
139 lines
6.9 KiB
PHP
139 lines
6.9 KiB
PHP
<?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);
|
||
});
|