Files
server/tests/php/Unit/Preview/PreviewManagerTest.php
T
Sebastian a0093c9547 feat: implement 3 variants
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-29 19:10:48 -04:00

380 lines
15 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::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}";
}
}