feat: implement preview manager
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Preview;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
use KTXC\Models\Tenant\TenantObject;
|
||||
use KTXC\Preview\PreviewManager;
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\SystemStore\SystemStoreManager;
|
||||
use KTXF\Preview\PreviewGenerationException;
|
||||
use KTXF\Preview\PreviewRequest;
|
||||
use KTXF\Preview\PreviewResult;
|
||||
use KTXF\Preview\PreviewSource;
|
||||
use KTXF\Preview\Provider\PreviewProviderInterface;
|
||||
use KTXF\Resource\BinaryResource;
|
||||
use KTXF\Resource\Provider\ProviderInterface;
|
||||
use KTXF\SystemStore\BlobInfo;
|
||||
use KTXF\SystemStore\Provider\ProviderBaseInterface;
|
||||
use KTXF\SystemStore\Service\SystemStoreServiceInterface;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\MockObject\Stub;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class PreviewManagerTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function returnsAValidCacheHitWithoutGenerating(): void
|
||||
{
|
||||
$source = $this->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}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user