4dd09cc7ec
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
420 lines
17 KiB
PHP
420 lines
17 KiB
PHP
<?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::never())->method('warning');
|
|
|
|
self::assertNull($this->manager(
|
|
$provider,
|
|
$service,
|
|
providerManager: $providerManager,
|
|
logger: $logger,
|
|
)->fetch('tenant-a', $this->source()));
|
|
}
|
|
|
|
#[Test]
|
|
public function missingPreviewStorageIsUnavailableWithoutLoggingOrProviderLookup(): void
|
|
{
|
|
$provider = $this->provider();
|
|
$provider->expects(self::never())->method('generate');
|
|
$providerManager = $this->createMock(ProviderManager::class);
|
|
$providerManager->expects(self::never())->method('providers');
|
|
$service = $this->createMock(SystemStoreServiceInterface::class);
|
|
$service->expects(self::never())->method('stat');
|
|
$logger = $this->createMock(LoggerInterface::class);
|
|
$logger->expects(self::never())->method('warning');
|
|
|
|
self::assertNull($this->manager(
|
|
$provider,
|
|
$service,
|
|
new TenantConfiguration(),
|
|
$providerManager,
|
|
$logger,
|
|
)->fetch('tenant-a', $this->source()));
|
|
}
|
|
|
|
#[Test]
|
|
public function reportsStorageAndGenerationAvailabilitySeparately(): void
|
|
{
|
|
$provider = $this->provider();
|
|
$providerManager = $this->createMock(ProviderManager::class);
|
|
$providerManager->expects(self::once())->method('providers')->willReturn([]);
|
|
$logger = $this->createMock(LoggerInterface::class);
|
|
$logger->expects(self::once())->method('debug')->with(
|
|
'No preview generation provider is available',
|
|
['tenantId' => 'tenant-a'],
|
|
);
|
|
|
|
self::assertSame(
|
|
['enabled' => true, 'storage' => true, 'generation' => false],
|
|
$this->manager(
|
|
$provider,
|
|
$this->createStub(SystemStoreServiceInterface::class),
|
|
providerManager: $providerManager,
|
|
logger: $logger,
|
|
)->availability('tenant-a'),
|
|
);
|
|
}
|
|
|
|
#[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([
|
|
'version' => 2,
|
|
'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}";
|
|
}
|
|
}
|