From 3b613fadf48cb6731d5c60ddce80a4cdf1124b63 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 22 Sep 2026 10:13:49 +0000 Subject: [PATCH] =?UTF-8?q?images:=20press-release-images:regenerate-varia?= =?UTF-8?q?nts=20--only=3Dog=20f=C3=BCr=20den=20Bestand,=20Testhelfer=20ei?= =?UTF-8?q?ndeutig=20benannt=20(Bildkennzeichnung=20H.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../RegeneratePressReleaseImageVariants.php | 101 ++++++++++++++++++ .../Images/OpenGraphAndTeaserCreditTest.php | 29 +++++ .../Feature/Legal/LegalAcceptanceGateTest.php | 10 +- 3 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 app/Console/Commands/RegeneratePressReleaseImageVariants.php diff --git a/app/Console/Commands/RegeneratePressReleaseImageVariants.php b/app/Console/Commands/RegeneratePressReleaseImageVariants.php new file mode 100644 index 00000000..d77cdf76 --- /dev/null +++ b/app/Console/Commands/RegeneratePressReleaseImageVariants.php @@ -0,0 +1,101 @@ +option('only') !== 'og') { + $this->error('Derzeit wird nur --only=og unterstützt.'); + + return self::FAILURE; + } + + $query = PressReleaseImage::query() + ->with(['pressRelease' => fn ($q) => $q->withoutGlobalScope(PortalScope::class)->with('company')]) + ->orderBy('id'); + + if ($this->option('published')) { + $query->whereHas('pressRelease', fn ($q) => $q->withoutGlobalScope(PortalScope::class)->where('status', 'published')); + } + + if (! $this->option('force')) { + $query->where(function ($q): void { + $q->whereNull('variants')->orWhereNull('variants->og'); + }); + } + + $total = $query->count(); + $limit = (int) $this->option('limit'); + $execute = (bool) $this->option('execute'); + + $this->info(($execute ? 'Erzeuge' : 'Trockenlauf:').' OG-Varianten für '.($limit > 0 ? min($limit, $total) : $total).' von '.$total.' Bildern.'); + + if (! $execute) { + return self::SUCCESS; + } + + $done = 0; + $skipped = 0; + + $query->chunkById(200, function ($images) use (&$done, &$skipped, $limit): bool { + foreach ($images as $image) { + if ($limit > 0 && $done >= $limit) { + return false; + } + + $caption = $image->pressRelease ? ImageCredit::for($image, $image->pressRelease->company)->line() : null; + $path = $this->imageService->regenerateOgVariant( + $image->disk, + $image->variants, + $image->path, + $image->focus_x !== null && $image->focus_y !== null ? ['x' => $image->focus_x, 'y' => $image->focus_y] : null, + (bool) $image->no_crop, + $caption, + ); + + if ($path === null) { + $skipped++; + + continue; + } + + $image->forceFill(['variants' => array_merge((array) $image->variants, ['og' => $path])])->saveQuietly(); + $done++; + } + + return true; + }); + + $this->info("OG-Varianten erzeugt: {$done}, übersprungen (keine Quelle): {$skipped}."); + + return self::SUCCESS; + } +} diff --git a/tests/Feature/Images/OpenGraphAndTeaserCreditTest.php b/tests/Feature/Images/OpenGraphAndTeaserCreditTest.php index 7036d09b..2c8af7d3 100644 --- a/tests/Feature/Images/OpenGraphAndTeaserCreditTest.php +++ b/tests/Feature/Images/OpenGraphAndTeaserCreditTest.php @@ -137,3 +137,32 @@ test('teasers carry the credit in alt and title and the hero shows a caption', f ->assertSee('alt="Teaser-Meldung · Foto: Anna Beispiel · KI-generiert"', false) ->assertSee('data-image-credit-teaser', false); }); + +test('the regenerate command builds missing og variants for the existing stock with the credit line', function () { + /** @var TestCase $this */ + $company = Company::factory()->presseecho()->create(['name' => 'Bestand GmbH', 'is_active' => true]); + $pr = PressRelease::factory()->published()->for($company)->create(['published_ever_at' => now()->subDay()]); + + $upload = UploadedFile::fake()->image('alt.jpg', 1600, 900)->size(100); + $stored = (new ImageService)->storePressReleaseImage($upload, $pr->id, 'local'); + $variants = $stored['variants']; + Storage::disk('local')->delete($variants['og']); + unset($variants['og']); + + $image = PressReleaseImage::factory()->for($pr)->legacy()->create(['disk' => 'local', 'path' => $stored['path'], 'variants' => $variants]); + + $this->artisan('press-release-images:regenerate-variants', ['--only' => 'og', '--published' => true]) + ->expectsOutputToContain('Trockenlauf: OG-Varianten für 1 von 1 Bildern.') + ->assertSuccessful(); + + expect($image->fresh()->variants)->not->toHaveKey('og'); + + $this->artisan('press-release-images:regenerate-variants', ['--only' => 'og', '--published' => true, '--execute' => true]) + ->expectsOutputToContain('OG-Varianten erzeugt: 1') + ->assertSuccessful(); + + $og = $image->fresh()->variants['og'] ?? null; + expect($og)->not->toBeNull() + ->and(Storage::disk('local')->exists($og))->toBeTrue() + ->and(getimagesize(Storage::disk('local')->path($og))[0])->toBe(1200); +}); diff --git a/tests/Feature/Legal/LegalAcceptanceGateTest.php b/tests/Feature/Legal/LegalAcceptanceGateTest.php index a5ee78e8..cc8206a9 100644 --- a/tests/Feature/Legal/LegalAcceptanceGateTest.php +++ b/tests/Feature/Legal/LegalAcceptanceGateTest.php @@ -29,7 +29,7 @@ beforeEach(function (): void { /** * @return array{owner: User, pr: PressRelease} */ -function gateDraft(): array +function legalGateDraft(): array { $owner = User::factory()->create(['is_active' => true]); $owner->assignRole('customer'); @@ -49,7 +49,7 @@ function gateDraft(): array test('with the gate off nothing is required and submitting works as before', function () { /** @var TestCase $this */ - ['owner' => $owner, 'pr' => $pr] = gateDraft(); + ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); $this->actingAs($owner); expect(app(LegalAcceptanceGate::class)->requiredFor($owner))->toBeEmpty(); @@ -68,7 +68,7 @@ test('with the gate off nothing is required and submitting works as before', fun test('with the gate on the submit requires an active acceptance of agb and guidelines and records both', function () { /** @var TestCase $this */ config()->set('legal.acceptance_gate', true); - ['owner' => $owner, 'pr' => $pr] = gateDraft(); + ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); $this->actingAs($owner); expect(app(LegalAcceptanceGate::class)->requiredFor($owner)->pluck('type')->all())->toBe(['agb', 'richtlinien']); @@ -99,7 +99,7 @@ test('with the gate on the submit requires an active acceptance of agb and guide test('acceptances are insert-only', function () { config()->set('legal.acceptance_gate', true); - ['owner' => $owner] = gateDraft(); + ['owner' => $owner] = legalGateDraft(); $gate = app(LegalAcceptanceGate::class); $rows = $gate->accept($owner, $gate->requiredFor($owner), 'gate', '203.0.113.5', 'Pest'); @@ -112,7 +112,7 @@ test('acceptances are insert-only', function () { test('the api answers 428 with the required documents until they are accepted', function () { /** @var TestCase $this */ config()->set('legal.acceptance_gate', true); - ['owner' => $owner, 'pr' => $pr] = gateDraft(); + ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); Sanctum::actingAs($owner, ['press-releases:write', 'press-releases:read']); $this->postJson("/api/v1/press-releases/{$pr->id}/submit", ['ai_text_declaration' => 'none'])