diff --git a/core/lib/Models/Tenant/TenantPreview.php b/core/lib/Models/Tenant/TenantPreview.php index 77efb4b..9650648 100644 --- a/core/lib/Models/Tenant/TenantPreview.php +++ b/core/lib/Models/Tenant/TenantPreview.php @@ -11,12 +11,11 @@ final class TenantPreview extends JsonSerializableObject { protected bool $enabled = true; protected string $store = 'previews'; - protected TenantPreviewGeneration $generation; + protected int $maxSourceSize = 26214400; protected TenantPreviewVariants $variants; public function __construct() { - $this->generation = new TenantPreviewGeneration(); $this->variants = new TenantPreviewVariants(); } @@ -40,11 +39,11 @@ final class TenantPreview extends JsonSerializableObject $this->store = $data['store']; } - if (array_key_exists('generation', $data)) { - if (!is_array($data['generation'])) { - throw new InvalidArgumentException('Preview generation configuration must be an object'); + if (array_key_exists('maxSourceSize', $data)) { + if (!is_int($data['maxSourceSize']) || $data['maxSourceSize'] < 1) { + throw new InvalidArgumentException('Preview maximum source size must be a positive integer'); } - $this->generation->jsonDeserialize($data['generation']); + $this->maxSourceSize = $data['maxSourceSize']; } if (array_key_exists('variants', $data)) { @@ -71,9 +70,9 @@ final class TenantPreview extends JsonSerializableObject return $this->store; } - public function generation(): TenantPreviewGeneration + public function maxSourceSize(): int { - return $this->generation; + return $this->maxSourceSize; } public function variants(): TenantPreviewVariants diff --git a/core/lib/Models/Tenant/TenantPreviewGeneration.php b/core/lib/Models/Tenant/TenantPreviewGeneration.php deleted file mode 100644 index 5b92512..0000000 --- a/core/lib/Models/Tenant/TenantPreviewGeneration.php +++ /dev/null @@ -1,72 +0,0 @@ -mode = $data['mode']; - } - - foreach ([ - 'maxSourceSize' => 'maximum source size', - 'timeoutSeconds' => 'timeout', - 'retryDelaySeconds' => 'retry delay', - ] as $property => $label) { - if (!array_key_exists($property, $data)) { - continue; - } - if (!is_int($data[$property]) || $data[$property] < 1) { - throw new InvalidArgumentException("Preview {$label} must be a positive integer"); - } - $this->$property = $data[$property]; - } - - return $this; - } - - public function mode(): string - { - return $this->mode; - } - - public function maxSourceSize(): int - { - return $this->maxSourceSize; - } - - public function timeoutSeconds(): int - { - return $this->timeoutSeconds; - } - - public function retryDelaySeconds(): int - { - return $this->retryDelaySeconds; - } -} diff --git a/core/lib/Preview/Preview.php b/core/lib/Preview/Preview.php new file mode 100644 index 0000000..c64b53f --- /dev/null +++ b/core/lib/Preview/Preview.php @@ -0,0 +1,19 @@ +tenants->fetchById($tenantId); + if ($tenant === null) { + return null; + } + + $configuration = $tenant->getConfiguration()->preview(); + $variantConfiguration = $configuration->variants()->variant($variant); + if ( + !$configuration->enabled() + || $variantConfiguration === null + || ($source->size !== null && $source->size > $configuration->maxSourceSize()) + ) { + return null; + } + + $request = new PreviewRequest( + maxWidth: $variantConfiguration->width, + maxHeight: $variantConfiguration->height, + preferredMimeType: $variantConfiguration->format, + quality: $variantConfiguration->quality, + maxSourceSize: $configuration->maxSourceSize(), + ); + + try { + $cached = $this->readCache( + $tenantId, + $configuration->store(), + $source, + $request, + ); + if ($cached !== null) { + return $cached; + } + + $provider = $this->selectProvider($source->mimeType, $request); + $result = $provider->generate($source, $request); + $blob = $this->writeCache( + $tenantId, + $configuration->store(), + $source, + $request, + $result, + ); + + return $this->readStoredPreview( + $tenantId, + $configuration->store(), + $blob, + $request, + ); + } catch (Throwable $exception) { + $this->logger->warning('Preview unavailable', [ + 'tenantId' => $tenantId, + 'sourceType' => $source->sourceType, + 'variant' => $variant, + 'exception' => $exception, + ]); + return null; + } + } + + private function readCache( + string $tenantId, + string $store, + PreviewSource $source, + PreviewRequest $request, + ): ?Preview { + $key = $this->cacheKey($tenantId, $source, $request); + $blob = $this->stores->stat($tenantId, $store, $key); + if (!$this->validBlob($blob, $key, $request)) { + return null; + } + + return $this->readStoredPreview($tenantId, $store, $blob, $request); + } + + private function readStoredPreview( + string $tenantId, + string $store, + BlobInfo $blob, + PreviewRequest $request, + ): ?Preview { + $resource = $this->stores->read($tenantId, $store, $blob->key); + if ($resource === null) { + return null; + } + try { + if (MimeType::normalize($resource->mimeType()) !== $request->preferredMimeType) { + return null; + } + } catch (Throwable) { + return null; + } + + return new Preview( + resource: $resource, + size: $blob->size, + etag: $blob->etag, + width: $blob->attributes['width'] ?? null, + height: $blob->attributes['height'] ?? null, + ); + } + + private function writeCache( + string $tenantId, + string $store, + PreviewSource $source, + PreviewRequest $request, + PreviewResult $result, + ): BlobInfo { + $outputMimeType = MimeType::normalize($result->resource->mimeType()); + if ($outputMimeType !== $request->preferredMimeType) { + throw new PreviewGenerationException('Generated preview MIME does not match the requested output MIME'); + } + if ( + ($result->width !== null && $result->width > $request->maxWidth) + || ($result->height !== null && $result->height > $request->maxHeight) + ) { + throw new PreviewGenerationException('Generated preview dimensions exceed the requested limits'); + } + + return $this->stores->write( + $tenantId, + $store, + $this->cacheKey($tenantId, $source, $request), + $result->resource, + ['width' => $result->width, 'height' => $result->height], + ); + } + + private function cacheKey( + string $tenantId, + PreviewSource $source, + PreviewRequest $request, + ): string { + if (trim($tenantId) === '') { + throw new InvalidArgumentException('Preview cache keys require a tenant identifier'); + } + + $canonical = json_encode([ + 'tenantId' => $tenantId, + 'source' => [ + 'type' => $source->sourceType, + 'identity' => $source->identity, + 'signature' => $source->signature, + 'mimeType' => $source->mimeType, + ], + 'request' => [ + 'maxWidth' => $request->maxWidth, + 'maxHeight' => $request->maxHeight, + 'preferredMimeType' => $request->preferredMimeType, + 'quality' => $request->quality, + ], + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION); + $digest = hash('sha256', $canonical); + + return "generated/{$source->sourceType}/" . substr($digest, 0, 2) . "/{$digest}"; + } + + private function validBlob(?BlobInfo $blob, string $key, PreviewRequest $request): bool + { + if ($blob === null || $blob->key !== $key || $blob->size < 1) { + return false; + } + + $width = $blob->attributes['width'] ?? null; + $height = $blob->attributes['height'] ?? null; + if ( + ($width !== null && (!is_int($width) || $width < 1 || $width > $request->maxWidth)) + || ($height !== null && (!is_int($height) || $height < 1 || $height > $request->maxHeight)) + ) { + return false; + } + + try { + return MimeType::normalize($blob->mimeType) === $request->preferredMimeType; + } catch (Throwable) { + return false; + } + } + + private function selectProvider( + string $sourceMimeType, + PreviewRequest $request, + ): PreviewProviderInterface { + $sourceMimeType = MimeType::normalize($sourceMimeType); + $candidates = []; + + foreach ($this->providers->providers(ProviderInterface::TYPE_PREVIEW) as $identifier => $provider) { + if ( + !$provider instanceof PreviewProviderInterface + || !$provider->supports($sourceMimeType, $request) + ) { + continue; + } + + $candidates[] = ['identifier' => $identifier, 'provider' => $provider]; + } + + if ($candidates === []) { + throw new PreviewGenerationException( + "No preview provider supports {$sourceMimeType} to {$request->preferredMimeType}" + ); + } + + usort($candidates, static function (array $left, array $right): int { + return ($right['provider']->priority() <=> $left['provider']->priority()) + ?: strcmp($left['identifier'], $right['identifier']); + }); + + return $candidates[0]['provider']; + } +} diff --git a/shared/lib/Preview/PreviewException.php b/shared/lib/Preview/PreviewException.php deleted file mode 100644 index b180d8a..0000000 --- a/shared/lib/Preview/PreviewException.php +++ /dev/null @@ -1,11 +0,0 @@ -variant) !== 1) { - throw new InvalidArgumentException('Preview variant must use lowercase letters, numbers, and hyphens'); - } if ($this->maxWidth < 1 || $this->maxHeight < 1) { throw new InvalidArgumentException('Preview dimensions must be positive'); } if ($this->quality < 1 || $this->quality > 100) { throw new InvalidArgumentException('Preview quality must be between 1 and 100'); } - if ($this->page !== null && $this->page < 1) { - throw new InvalidArgumentException('Preview page numbers start at 1'); - } - if ($this->timestamp !== null && $this->timestamp < 0) { - throw new InvalidArgumentException('Preview timestamps cannot be negative'); + if ($this->maxSourceSize < 1) { + throw new InvalidArgumentException('Preview maximum source size must be positive'); } $this->preferredMimeType = MimeType::normalize($preferredMimeType); } - /** - * Fixed-order scalar representation for cache-key generation. - * - * @return array{variant:string,maxWidth:int,maxHeight:int,preferredMimeType:string,quality:int,page:?int,timestamp:?float} - */ - public function normalized(): array - { - return [ - 'variant' => $this->variant, - 'maxWidth' => $this->maxWidth, - 'maxHeight' => $this->maxHeight, - 'preferredMimeType' => $this->preferredMimeType, - 'quality' => $this->quality, - 'page' => $this->page, - 'timestamp' => $this->timestamp, - ]; - } } diff --git a/shared/lib/Preview/PreviewSource.php b/shared/lib/Preview/PreviewSource.php index a81e406..78b796b 100644 --- a/shared/lib/Preview/PreviewSource.php +++ b/shared/lib/Preview/PreviewSource.php @@ -22,17 +22,16 @@ final readonly class PreviewSource string $mimeType, public ?int $size, public Closure $resourceFactory, - public ?string $selector = null, ) { - if ($this->sourceType === '' || $this->identity === '' || $this->signature === '') { - throw new InvalidArgumentException('Preview sources require a type, identity, and content signature'); + if (preg_match('/^[a-z][a-z0-9-]*$/', $this->sourceType) !== 1) { + throw new InvalidArgumentException('Preview source type must use lowercase letters, numbers, and hyphens'); + } + if ($this->identity === '' || $this->signature === '') { + throw new InvalidArgumentException('Preview sources require an identity and content signature'); } if ($this->size !== null && $this->size < 0) { throw new InvalidArgumentException('Preview source size cannot be negative'); } - if ($this->selector === '') { - throw new InvalidArgumentException('Preview source selectors must be null or non-empty'); - } $this->mimeType = MimeType::normalize($mimeType); } @@ -40,7 +39,7 @@ final readonly class PreviewSource { $resource = ($this->resourceFactory)(); if (!$resource instanceof BinaryResource) { - throw new PreviewException('Preview source factory did not return a BinaryResource'); + throw new PreviewGenerationException('Preview source factory did not return a BinaryResource'); } return $resource; } diff --git a/shared/lib/Preview/Provider/PreviewProviderInterface.php b/shared/lib/Preview/Provider/PreviewProviderInterface.php index 3ed9925..01c1f1f 100644 --- a/shared/lib/Preview/Provider/PreviewProviderInterface.php +++ b/shared/lib/Preview/Provider/PreviewProviderInterface.php @@ -11,17 +11,9 @@ use KTXF\Resource\Provider\ProviderInterface; interface PreviewProviderInterface extends ProviderInterface { - /** @return list Exact MIME types or type wildcards such as image/*. */ - public function supportedMimeTypes(): array; - - /** @return list */ - public function outputMimeTypes(): array; - public function supports(string $sourceMimeType, PreviewRequest $request): bool; public function generate(PreviewSource $source, PreviewRequest $request): PreviewResult; - public function generatorVersion(): string; - public function priority(): int; } diff --git a/shared/lib/Preview/UnsupportedPreviewException.php b/shared/lib/Preview/UnsupportedPreviewException.php deleted file mode 100644 index ef38cad..0000000 --- a/shared/lib/Preview/UnsupportedPreviewException.php +++ /dev/null @@ -1,9 +0,0 @@ -enabled()); self::assertSame('previews', $preview->store()); - self::assertSame(TenantPreviewGeneration::MODE_EAGER_WITH_LAZY_FALLBACK, $preview->generation()->mode()); - self::assertSame(104857600, $preview->generation()->maxSourceSize()); - self::assertSame(30, $preview->generation()->timeoutSeconds()); - self::assertSame(3600, $preview->generation()->retryDelaySeconds()); + self::assertSame(26214400, $preview->maxSourceSize()); self::assertSame(640, $preview->variants()->variant('popover')?->width); self::assertSame(440, $preview->variants()->variant('popover')?->height); self::assertSame('image/webp', $preview->variants()->variant('popover')?->format); @@ -36,12 +32,7 @@ final class PreviewConfigurationTest extends TestCase $data = [ 'enabled' => true, 'store' => 'generated-content', - 'generation' => [ - 'mode' => TenantPreviewGeneration::MODE_LAZY, - 'maxSourceSize' => 1024, - 'timeoutSeconds' => 5, - 'retryDelaySeconds' => 60, - ], + 'maxSourceSize' => 1024, 'variants' => [ 'popover' => [ 'width' => 800, @@ -55,7 +46,7 @@ final class PreviewConfigurationTest extends TestCase $configuration = (new TenantConfiguration())->jsonDeserialize(['preview' => $data]); self::assertSame('generated-content', $configuration->preview()->store()); - self::assertSame(TenantPreviewGeneration::MODE_LAZY, $configuration->preview()->generation()->mode()); + self::assertSame(1024, $configuration->preview()->maxSourceSize()); self::assertSame(['popover'], array_keys($configuration->preview()->variants()->all())); self::assertSame('image/png', $configuration->preview()->variants()->variant('popover')?->format); $data['variants']['popover']['format'] = 'image/png'; @@ -96,8 +87,7 @@ final class PreviewConfigurationTest extends TestCase public static function invalidConfigurations(): iterable { yield 'invalid store' => [['store' => '../previews']]; - yield 'unknown generation mode' => [['generation' => ['mode' => 'eager']]]; - yield 'zero source limit' => [['generation' => ['maxSourceSize' => 0]]]; + yield 'zero source limit' => [['maxSourceSize' => 0]]; yield 'invalid variant name' => [['variants' => ['../popover' => []]]]; yield 'invalid dimensions' => [['variants' => ['popover' => ['width' => 0]]]]; yield 'invalid output MIME' => [['variants' => ['popover' => ['format' => 'webp']]]]; diff --git a/tests/php/Unit/Preview/PreviewContractsTest.php b/tests/php/Unit/Preview/PreviewContractsTest.php index 7115cc7..54cbaba 100644 --- a/tests/php/Unit/Preview/PreviewContractsTest.php +++ b/tests/php/Unit/Preview/PreviewContractsTest.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace KTXT\Unit\Preview; use KTXF\Preview\MimeType; -use KTXF\Preview\PreviewException; +use KTXF\Preview\PreviewGenerationException; use KTXF\Preview\PreviewRequest; use KTXF\Preview\PreviewResult; use KTXF\Preview\PreviewSource; @@ -26,28 +26,17 @@ final class PreviewContractsTest extends TestCase } #[Test] - public function normalizesMimeTypesAndMatchesWildcards(): void + public function normalizesMimeTypes(): void { self::assertSame('image/jpeg', MimeType::normalize(' Image/JPEG; charset=binary ')); - self::assertTrue(MimeType::matches('image/*', 'IMAGE/WEBP')); - self::assertTrue(MimeType::matches('application/pdf', 'application/pdf')); - self::assertFalse(MimeType::matches('image/*', 'application/pdf')); } #[Test] - public function requestHasADeterministicNormalizedRepresentation(): void + public function requestNormalizesItsOutputMimeType(): void { - $request = new PreviewRequest('popover', 640, 440, 'IMAGE/WEBP', 80, page: 1); + $request = new PreviewRequest(640, 440, 'IMAGE/WEBP', 80); - self::assertSame([ - 'variant' => 'popover', - 'maxWidth' => 640, - 'maxHeight' => 440, - 'preferredMimeType' => 'image/webp', - 'quality' => 80, - 'page' => 1, - 'timestamp' => null, - ], $request->normalized()); + self::assertSame('image/webp', $request->preferredMimeType); } #[Test] @@ -75,7 +64,7 @@ final class PreviewContractsTest extends TestCase public function sourceRejectsAnInvalidFactoryResult(): void { $source = new PreviewSource('document', 'entity', 'revision', 'image/jpeg', 1, fn() => 'invalid'); - $this->expectException(PreviewException::class); + $this->expectException(PreviewGenerationException::class); $source->open(); } diff --git a/tests/php/Unit/Preview/PreviewManagerTest.php b/tests/php/Unit/Preview/PreviewManagerTest.php new file mode 100644 index 0000000..140fd72 --- /dev/null +++ b/tests/php/Unit/Preview/PreviewManagerTest.php @@ -0,0 +1,379 @@ +source(); + $provider = $this->provider(); + $request = $this->request(); + $key = $this->cacheKey($source, $request); + $blob = $this->blob($this->physicalKey($key)); + $service = $this->createMock(SystemStoreServiceInterface::class); + $service->expects(self::once())->method('stat')->willReturn($blob); + $service->expects(self::once())->method('read')->willReturn($this->resource()); + $service->expects(self::never())->method('write'); + $provider->expects(self::never())->method('generate'); + + $cached = $this->manager($provider, $service)->fetch('tenant-a', $source); + + self::assertSame($blob->etag, $cached?->etag); + self::assertSame(7, $cached?->size); + } + + #[Test] + public function generatesStoresAndReturnsAPreviewOnCacheMiss(): void + { + $source = $this->source(); + $provider = $this->provider(); + $request = $this->request(); + $key = $this->cacheKey($source, $request); + $result = new PreviewResult($this->resource(), 320, 200); + $blob = $this->blob($this->physicalKey($key)); + $provider->expects(self::once())->method('generate')->with($source, self::isInstanceOf(PreviewRequest::class))->willReturn($result); + $service = $this->createMock(SystemStoreServiceInterface::class); + $service->expects(self::once())->method('stat')->willReturn(null); + $service->expects(self::once())->method('write')->with( + $this->physicalKey($key), + $result->resource, + ['width' => 320, 'height' => 200], + null, + )->willReturn($blob); + $service->expects(self::once())->method('read')->willReturn($this->resource()); + + $cached = $this->manager($provider, $service)->fetch('tenant-a', $source); + + self::assertSame($blob->etag, $cached?->etag); + } + + #[Test] + public function disabledOrOversizedSourcesDoNotReachProviders(): void + { + $configuration = (new TenantConfiguration())->jsonDeserialize([ + 'preview' => ['enabled' => false], + ]); + $providerManager = $this->createMock(ProviderManager::class); + $providerManager->expects(self::never())->method('providers'); + $service = $this->createMock(SystemStoreServiceInterface::class); + $service->expects(self::never())->method('stat'); + $provider = $this->provider(); + $provider->expects(self::never())->method('generate'); + + self::assertNull($this->manager( + $provider, + $service, + $configuration, + $providerManager, + )->fetch('tenant-a', $this->source())); + } + + #[Test] + public function generationFailuresBecomePreviewUnavailable(): void + { + $source = $this->source(); + $provider = $this->provider(); + $provider->expects(self::once())->method('generate')->willThrowException(new \RuntimeException('decode failed')); + $service = $this->createMock(SystemStoreServiceInterface::class); + $service->expects(self::once())->method('stat')->willReturn(null); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('warning')->with('Preview unavailable', self::arrayHasKey('exception')); + + self::assertNull($this->manager($provider, $service, logger: $logger)->fetch('tenant-a', $source)); + } + + #[Test] + public function selectsACompatibleProviderByPriorityThenRegistryIdentifier(): void + { + $source = $this->source(); + $alpha = $this->candidate('alpha', 20, true); + $zeta = $this->candidate('zeta', 20, true); + $low = $this->candidate('low', 10, true); + $rejecting = $this->candidate('rejecting', 100, false); + $providerManager = $this->createMock(ProviderManager::class); + $providerManager->expects(self::once()) + ->method('providers') + ->with(ProviderInterface::TYPE_PREVIEW) + ->willReturn([ + 'rejecting' => $rejecting, + 'zeta' => $zeta, + 'low' => $low, + 'alpha' => $alpha, + ]); + + $key = $this->cacheKey($source, $this->request()); + $result = new PreviewResult($this->resource(), 320, 200); + $alpha->expects(self::once())->method('generate')->willReturn($result); + $zeta->expects(self::never())->method('generate'); + $low->expects(self::never())->method('generate'); + $rejecting->expects(self::never())->method('generate'); + $service = $this->createMock(SystemStoreServiceInterface::class); + $blob = $this->blob($this->physicalKey($key)); + $service->expects(self::once())->method('stat')->with($this->physicalKey($key))->willReturn(null); + $service->expects(self::once())->method('write')->willReturn($blob); + $service->expects(self::once())->method('read')->willReturn($this->resource()); + + $cached = $this->manager($alpha, $service, providerManager: $providerManager)->fetch('tenant-a', $source); + + self::assertSame($blob->etag, $cached?->etag); + } + + #[Test] + public function unsupportedRequestsBecomePreviewUnavailable(): void + { + $provider = $this->candidate('pdf', 10, false); + $providerManager = $this->createMock(ProviderManager::class); + $providerManager->expects(self::once())->method('providers')->willReturn(['pdf' => $provider]); + $service = $this->createMock(SystemStoreServiceInterface::class); + $service->expects(self::once())->method('stat')->willReturn(null); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('warning')->with( + 'Preview unavailable', + self::callback(static fn(array $context): bool => + ($context['exception'] ?? null) instanceof PreviewGenerationException + ), + ); + + self::assertNull($this->manager( + $provider, + $service, + providerManager: $providerManager, + logger: $logger, + )->fetch('tenant-a', $this->source())); + } + + #[Test] + public function cacheKeysAreDeterministicOpaqueAndChangeWithSourceIdentity(): void + { + $provider = $this->provider(); + $provider->expects(self::never())->method('generate'); + $providerManager = $this->createMock(ProviderManager::class); + $providerManager->expects(self::never())->method('providers'); + $observedKeys = []; + $service = $this->createMock(SystemStoreServiceInterface::class); + $service->expects(self::exactly(3))->method('stat')->willReturnCallback( + function (string $key) use (&$observedKeys): BlobInfo { + $observedKeys[] = $key; + return $this->blob($key); + }, + ); + $service->expects(self::exactly(3))->method('read')->willReturnCallback(fn() => $this->resource()); + $manager = $this->manager($provider, $service, providerManager: $providerManager); + $source = $this->source(); + $changedSource = new PreviewSource( + 'document', + 'quarterly-report', + 'revision-2', + 'image/jpeg', + 1234, + fn(): BinaryResource => $this->resource('image/jpeg'), + ); + + self::assertNotNull($manager->fetch('tenant-a', $source)); + self::assertNotNull($manager->fetch('tenant-a', $source)); + self::assertNotNull($manager->fetch('tenant-a', $changedSource)); + + self::assertSame($observedKeys[0], $observedKeys[1]); + self::assertNotSame($observedKeys[0], $observedKeys[2]); + self::assertMatchesRegularExpression( + '~^tenant-previews/generated/document/[a-f0-9]{2}/[a-f0-9]{64}$~', + $observedKeys[0], + ); + self::assertStringNotContainsString('tenant-a', $observedKeys[0]); + self::assertStringNotContainsString('quarterly-report', $observedKeys[0]); + } + + #[Test] + public function invalidCachedMetadataIsTreatedAsAMiss(): void + { + $provider = $this->provider(); + $provider->expects(self::once())->method('generate')->willThrowException(new \RuntimeException('stop after miss')); + $service = $this->createMock(SystemStoreServiceInterface::class); + $service->expects(self::once())->method('stat')->willReturnCallback( + fn(string $key): BlobInfo => $this->blob($key, ['width' => 900, 'height' => 200]), + ); + $service->expects(self::never())->method('read'); + $service->expects(self::never())->method('write'); + + self::assertNull($this->manager($provider, $service)->fetch('tenant-a', $this->source())); + } + + #[Test] + public function refusesGeneratedContentWithTheWrongMimeType(): void + { + $provider = $this->provider(); + $provider->expects(self::once())->method('generate')->willReturn( + new PreviewResult($this->resource('image/png'), 320, 200), + ); + $service = $this->createMock(SystemStoreServiceInterface::class); + $service->expects(self::once())->method('stat')->willReturn(null); + $service->expects(self::never())->method('write'); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('warning')->with( + 'Preview unavailable', + self::callback(static fn(array $context): bool => + ($context['exception'] ?? null) instanceof PreviewGenerationException + ), + ); + + self::assertNull($this->manager($provider, $service, logger: $logger)->fetch('tenant-a', $this->source())); + } + + private function manager( + PreviewProviderInterface $provider, + SystemStoreServiceInterface $service, + ?TenantConfiguration $configuration = null, + ?ProviderManager $providerManager = null, + ?LoggerInterface $logger = null, + ): PreviewManager { + $effectiveConfiguration = $configuration ?? new TenantConfiguration(); + if ($configuration === null) { + $effectiveConfiguration->jsonDeserialize([ + 'stores' => [ + 'previews' => [ + 'provider' => 'storage-provider', + 'service' => 'preview-storage', + 'namespace' => 'tenant-previews', + ], + ], + ]); + } + $tenant = (new TenantObject()) + ->setIdentifier('tenant-a') + ->setConfiguration($effectiveConfiguration); + $tenants = $this->createStub(TenantService::class); + $tenants->method('fetchById')->willReturn($tenant); + + if ($providerManager === null) { + $providerManager = $this->createStub(ProviderManager::class); + $providerManager->method('providers')->willReturn(['imagemagick' => $provider]); + } + + $storageProvider = $this->createStub(ProviderBaseInterface::class); + $storageProvider->method('serviceFetch')->willReturn($service); + $systemProviders = $this->createStub(ProviderManager::class); + $systemProviders->method('resolve')->willReturn($storageProvider); + + return new PreviewManager( + $tenants, + $providerManager, + new SystemStoreManager($tenants, $systemProviders), + $logger ?? $this->createStub(LoggerInterface::class), + ); + } + + private function provider(): PreviewProviderInterface&MockObject + { + $provider = $this->createMock(PreviewProviderInterface::class); + $provider->method('identifier')->willReturn('imagemagick'); + $provider->method('supports')->willReturn(true); + $provider->method('priority')->willReturn(100); + return $provider; + } + + private function candidate( + string $identifier, + int $priority, + bool $supports, + ): PreviewProviderInterface&MockObject { + $provider = $this->createMock(PreviewProviderInterface::class); + $provider->method('identifier')->willReturn($identifier); + $provider->method('supports')->willReturn($supports); + $provider->method('priority')->willReturn($priority); + return $provider; + } + + private function source(): PreviewSource + { + return new PreviewSource( + 'document', + 'quarterly-report', + 'revision-1', + 'image/jpeg', + 1234, + fn(): BinaryResource => $this->resource('image/jpeg'), + ); + } + + private function request(): PreviewRequest + { + return new PreviewRequest(640, 440, 'image/webp', 80); + } + + private function resource(string $mimeType = 'image/webp'): BinaryResource + { + return new BinaryResource( + 'preview.bin', + $mimeType, + (static function (): \Generator { yield 'preview'; })(), + ); + } + + private function blob(string $key, array $attributes = ['width' => 320, 'height' => 200]): BlobInfo + { + return new BlobInfo( + key: $key, + mimeType: 'image/webp', + size: 7, + etag: 'etag-1', + modifiedAt: new DateTimeImmutable('2026-08-29T12:00:00+00:00'), + attributes: $attributes, + ); + } + + private function physicalKey(string $key): string + { + return 'tenant-previews/' . $key; + } + + private function cacheKey( + PreviewSource $source, + PreviewRequest $request, + ): string { + $canonical = json_encode([ + 'tenantId' => 'tenant-a', + 'source' => [ + 'type' => $source->sourceType, + 'identity' => $source->identity, + 'signature' => $source->signature, + 'mimeType' => $source->mimeType, + ], + 'request' => [ + 'maxWidth' => $request->maxWidth, + 'maxHeight' => $request->maxHeight, + 'preferredMimeType' => $request->preferredMimeType, + 'quality' => $request->quality, + ], + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION); + $digest = hash('sha256', $canonical); + + return "generated/{$source->sourceType}/" . substr($digest, 0, 2) . "/{$digest}"; + } +}