feat(service): lazy image resolver

This commit is contained in:
Thorsten Bus 2026-05-31 00:08:50 +02:00
parent 38e79553eb
commit 1ce30b76e3
5 changed files with 147 additions and 0 deletions

View file

@ -0,0 +1,16 @@
Task T5 fallthrough evidence
Covered fallback cases in tests/Feature/ServiceImageResolverTest.php:
- Service key visual exists in public storage -> returns service filename.
- Service key visual is null and global key visual exists in public storage -> returns global filename.
- Service key visual and global key visual are null -> returns null.
- Service key visual references a missing file, while global key visual exists -> skips missing service file and returns global filename.
- Background resolver covers the same three branches: service file, global fallback, null.
The implementation uses Storage::disk('public')->exists(...) before returning any referenced filename, so nonexistent paths fall through instead of being returned.
Verification:
- Initial RED: ddev exec php artisan test tests/Feature/ServiceImageResolverTest.php failed because App\Services\ServiceImageResolver did not exist.
- GREEN: ddev exec php artisan test tests/Feature/ServiceImageResolverTest.php passed after implementation.
- Full suite: ddev exec php artisan test passed with 519 tests / 2627 assertions.

View file

@ -0,0 +1,15 @@
Task T5 resolution order evidence
ServiceImageResolver implements lazy raw-filename resolution for service imagery:
- keyVisualFor(Service $service): checks $service->key_visual_filename first.
- backgroundFor(Service $service): checks $service->background_filename first.
- If the per-service filename is null or missing on Storage::disk('public'), the resolver checks the matching global Setting key.
- Global keys: current_key_visual, current_background.
- Return contract: raw relative filename such as slides/kv.jpg, never /storage/... URL.
- Pure behavior: no model updates, no Setting writes, no Storage writes.
Verification:
- ddev exec php artisan test tests/Feature/ServiceImageResolverTest.php: PASS (5 tests)
- ddev exec php artisan test: PASS (519 tests, 2627 assertions)
- ddev exec ./vendor/bin/pint app/Services/ServiceImageResolver.php tests/Feature/ServiceImageResolverTest.php: PASS

View file

@ -51,3 +51,11 @@ ## [2026-05-30] Task: T4
- NOTE: namenseinblenderMacro uses snake_case keys (name/uuid/collection_name/collection_uuid) — differs from existing `macroSettings` which uses camelCase (collectionName/collectionUuid). T10/T14 consumers must use snake_case for namenseinblender. - NOTE: namenseinblenderMacro uses snake_case keys (name/uuid/collection_name/collection_uuid) — differs from existing `macroSettings` which uses camelCase (collectionName/collectionUuid). T10/T14 consumers must use snake_case for namenseinblender.
- Test file `tests/Feature/NamenseinblenderSettingTest.php` (Pest, 4 tests). Inertia prop assertions use `->where('namenseinblenderMacro.name', ...)` dot-path on shared props from any authed page (settings.index). - Test file `tests/Feature/NamenseinblenderSettingTest.php` (Pest, 4 tests). Inertia prop assertions use `->where('namenseinblenderMacro.name', ...)` dot-path on shared props from any authed page (settings.index).
- 514 app tests green (510 baseline + 4 new). Pint clean. - 514 app tests green (510 baseline + 4 new). Pint clean.
## [2026-05-31] Task: T5
- `App\Services\ServiceImageResolver` is pure and lazy: it only reads `Service`, `Setting::get(...)`, and `Storage::disk('public')->exists(...)`; it writes nothing.
- Resolution order is per-service filename first, then global setting filename, then `null`; missing files are skipped/fall through at both levels.
- `keyVisualFor()` uses `key_visual_filename` then `current_key_visual`; `backgroundFor()` uses `background_filename` then `current_background`.
- Return contract is the raw storage-relative filename (`slides/abc.jpg`), NOT the Service model `/storage/...` URL accessor output.
- Test file `tests/Feature/ServiceImageResolverTest.php` covers service wins, global fallback, null, missing service-file fallthrough, and background resolution branches.
- Full suite green: `ddev exec php artisan test` → 519 passed, 2627 assertions. Pint clean for resolver + tests.

View file

@ -0,0 +1,35 @@
<?php
namespace App\Services;
use App\Models\Service;
use App\Models\Setting;
use Illuminate\Support\Facades\Storage;
class ServiceImageResolver
{
public function keyVisualFor(Service $service): ?string
{
return $this->resolve($service->key_visual_filename, 'current_key_visual');
}
public function backgroundFor(Service $service): ?string
{
return $this->resolve($service->background_filename, 'current_background');
}
private function resolve(?string $serviceFilename, string $settingKey): ?string
{
if ($serviceFilename !== null && Storage::disk('public')->exists($serviceFilename)) {
return $serviceFilename;
}
$globalFilename = Setting::get($settingKey);
if ($globalFilename !== null && Storage::disk('public')->exists($globalFilename)) {
return $globalFilename;
}
return null;
}
}

View file

@ -0,0 +1,73 @@
<?php
use App\Models\Service;
use App\Models\Setting;
use App\Services\ServiceImageResolver;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('public');
});
test('key visual uses service file when present', function () {
Storage::disk('public')->put('slides/kv.jpg', 'fake-content');
Setting::set('current_key_visual', 'slides/global-kv.jpg');
$service = Service::factory()->create([
'key_visual_filename' => 'slides/kv.jpg',
]);
expect(app(ServiceImageResolver::class)->keyVisualFor($service))->toBe('slides/kv.jpg');
});
test('key visual falls back to global setting when service value is empty', function () {
Storage::disk('public')->put('slides/global-kv.jpg', 'fake-content');
Setting::set('current_key_visual', 'slides/global-kv.jpg');
$service = Service::factory()->create([
'key_visual_filename' => null,
]);
expect(app(ServiceImageResolver::class)->keyVisualFor($service))->toBe('slides/global-kv.jpg');
});
test('key visual returns null when service and global values are empty', function () {
$service = Service::factory()->create([
'key_visual_filename' => null,
]);
expect(app(ServiceImageResolver::class)->keyVisualFor($service))->toBeNull();
});
test('key visual skips missing service file and falls through to existing global file', function () {
Storage::disk('public')->put('slides/global-kv.jpg', 'fake-content');
Setting::set('current_key_visual', 'slides/global-kv.jpg');
$service = Service::factory()->create([
'key_visual_filename' => 'slides/kv-missing.jpg',
]);
expect(app(ServiceImageResolver::class)->keyVisualFor($service))->toBe('slides/global-kv.jpg');
});
test('background uses service file falls back to global and returns null when none exist', function () {
Storage::disk('public')->put('slides/bg.jpg', 'fake-content');
Setting::set('current_background', 'slides/global-bg.jpg');
$service = Service::factory()->create([
'background_filename' => 'slides/bg.jpg',
]);
expect(app(ServiceImageResolver::class)->backgroundFor($service))->toBe('slides/bg.jpg');
Storage::disk('public')->delete('slides/bg.jpg');
Storage::disk('public')->put('slides/global-bg.jpg', 'fake-content');
$service->update(['background_filename' => null]);
expect(app(ServiceImageResolver::class)->backgroundFor($service->refresh()))->toBe('slides/global-bg.jpg');
Storage::disk('public')->delete('slides/global-bg.jpg');
Setting::set('current_background', null);
expect(app(ServiceImageResolver::class)->backgroundFor($service->refresh()))->toBeNull();
});