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, 640, '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}"; } }