feat: implement preview manager
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -11,12 +11,11 @@ final class TenantPreview extends JsonSerializableObject
|
|||||||
{
|
{
|
||||||
protected bool $enabled = true;
|
protected bool $enabled = true;
|
||||||
protected string $store = 'previews';
|
protected string $store = 'previews';
|
||||||
protected TenantPreviewGeneration $generation;
|
protected int $maxSourceSize = 26214400;
|
||||||
protected TenantPreviewVariants $variants;
|
protected TenantPreviewVariants $variants;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->generation = new TenantPreviewGeneration();
|
|
||||||
$this->variants = new TenantPreviewVariants();
|
$this->variants = new TenantPreviewVariants();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,11 +39,11 @@ final class TenantPreview extends JsonSerializableObject
|
|||||||
$this->store = $data['store'];
|
$this->store = $data['store'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (array_key_exists('generation', $data)) {
|
if (array_key_exists('maxSourceSize', $data)) {
|
||||||
if (!is_array($data['generation'])) {
|
if (!is_int($data['maxSourceSize']) || $data['maxSourceSize'] < 1) {
|
||||||
throw new InvalidArgumentException('Preview generation configuration must be an object');
|
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)) {
|
if (array_key_exists('variants', $data)) {
|
||||||
@@ -71,9 +70,9 @@ final class TenantPreview extends JsonSerializableObject
|
|||||||
return $this->store;
|
return $this->store;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generation(): TenantPreviewGeneration
|
public function maxSourceSize(): int
|
||||||
{
|
{
|
||||||
return $this->generation;
|
return $this->maxSourceSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function variants(): TenantPreviewVariants
|
public function variants(): TenantPreviewVariants
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Models\Tenant;
|
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
use KTXF\Json\JsonSerializableObject;
|
|
||||||
|
|
||||||
final class TenantPreviewGeneration extends JsonSerializableObject
|
|
||||||
{
|
|
||||||
public const MODE_LAZY = 'lazy';
|
|
||||||
public const MODE_EAGER_WITH_LAZY_FALLBACK = 'eager-with-lazy-fallback';
|
|
||||||
|
|
||||||
protected string $mode = self::MODE_EAGER_WITH_LAZY_FALLBACK;
|
|
||||||
protected int $maxSourceSize = 104857600;
|
|
||||||
protected int $timeoutSeconds = 30;
|
|
||||||
protected int $retryDelaySeconds = 3600;
|
|
||||||
|
|
||||||
public function jsonDeserialize(array|string $data): static
|
|
||||||
{
|
|
||||||
if (is_string($data)) {
|
|
||||||
$data = json_decode($data, true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists('mode', $data)) {
|
|
||||||
if (!is_string($data['mode']) || !in_array($data['mode'], [
|
|
||||||
self::MODE_LAZY,
|
|
||||||
self::MODE_EAGER_WITH_LAZY_FALLBACK,
|
|
||||||
], true)) {
|
|
||||||
throw new InvalidArgumentException('Unsupported preview generation mode');
|
|
||||||
}
|
|
||||||
$this->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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Preview;
|
||||||
|
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
|
|
||||||
|
final readonly class Preview
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public BinaryResource $resource,
|
||||||
|
public int $size,
|
||||||
|
public string $etag,
|
||||||
|
public ?int $width = null,
|
||||||
|
public ?int $height = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Preview;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use KTXC\Resource\ProviderManager;
|
||||||
|
use KTXC\Service\TenantService;
|
||||||
|
use KTXC\SystemStore\SystemStoreManager;
|
||||||
|
use KTXF\Preview\MimeType;
|
||||||
|
use KTXF\Preview\PreviewGenerationException;
|
||||||
|
use KTXF\Preview\PreviewRequest;
|
||||||
|
use KTXF\Preview\PreviewResult;
|
||||||
|
use KTXF\Preview\PreviewSource;
|
||||||
|
use KTXF\Preview\Provider\PreviewProviderInterface;
|
||||||
|
use KTXF\Resource\Provider\ProviderInterface;
|
||||||
|
use KTXF\SystemStore\BlobInfo;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final readonly class PreviewManager
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TenantService $tenants,
|
||||||
|
private ProviderManager $providers,
|
||||||
|
private SystemStoreManager $stores,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetch(
|
||||||
|
string $tenantId,
|
||||||
|
PreviewSource $source,
|
||||||
|
string $variant = 'popover',
|
||||||
|
): ?Preview {
|
||||||
|
$tenant = $this->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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXF\Preview;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class PreviewException extends RuntimeException
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXF\Preview;
|
namespace KTXF\Preview;
|
||||||
|
|
||||||
final class PreviewGenerationException extends PreviewException
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class PreviewGenerationException extends RuntimeException
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,47 +11,22 @@ final readonly class PreviewRequest
|
|||||||
public string $preferredMimeType;
|
public string $preferredMimeType;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $variant,
|
|
||||||
public int $maxWidth,
|
public int $maxWidth,
|
||||||
public int $maxHeight,
|
public int $maxHeight,
|
||||||
string $preferredMimeType,
|
string $preferredMimeType,
|
||||||
public int $quality,
|
public int $quality,
|
||||||
public ?int $page = null,
|
public int $maxSourceSize = 26214400,
|
||||||
public ?float $timestamp = null,
|
|
||||||
) {
|
) {
|
||||||
if (preg_match('/^[a-z][a-z0-9-]*$/', $this->variant) !== 1) {
|
|
||||||
throw new InvalidArgumentException('Preview variant must use lowercase letters, numbers, and hyphens');
|
|
||||||
}
|
|
||||||
if ($this->maxWidth < 1 || $this->maxHeight < 1) {
|
if ($this->maxWidth < 1 || $this->maxHeight < 1) {
|
||||||
throw new InvalidArgumentException('Preview dimensions must be positive');
|
throw new InvalidArgumentException('Preview dimensions must be positive');
|
||||||
}
|
}
|
||||||
if ($this->quality < 1 || $this->quality > 100) {
|
if ($this->quality < 1 || $this->quality > 100) {
|
||||||
throw new InvalidArgumentException('Preview quality must be between 1 and 100');
|
throw new InvalidArgumentException('Preview quality must be between 1 and 100');
|
||||||
}
|
}
|
||||||
if ($this->page !== null && $this->page < 1) {
|
if ($this->maxSourceSize < 1) {
|
||||||
throw new InvalidArgumentException('Preview page numbers start at 1');
|
throw new InvalidArgumentException('Preview maximum source size must be positive');
|
||||||
}
|
|
||||||
if ($this->timestamp !== null && $this->timestamp < 0) {
|
|
||||||
throw new InvalidArgumentException('Preview timestamps cannot be negative');
|
|
||||||
}
|
}
|
||||||
$this->preferredMimeType = MimeType::normalize($preferredMimeType);
|
$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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,17 +22,16 @@ final readonly class PreviewSource
|
|||||||
string $mimeType,
|
string $mimeType,
|
||||||
public ?int $size,
|
public ?int $size,
|
||||||
public Closure $resourceFactory,
|
public Closure $resourceFactory,
|
||||||
public ?string $selector = null,
|
|
||||||
) {
|
) {
|
||||||
if ($this->sourceType === '' || $this->identity === '' || $this->signature === '') {
|
if (preg_match('/^[a-z][a-z0-9-]*$/', $this->sourceType) !== 1) {
|
||||||
throw new InvalidArgumentException('Preview sources require a type, identity, and content signature');
|
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) {
|
if ($this->size !== null && $this->size < 0) {
|
||||||
throw new InvalidArgumentException('Preview source size cannot be negative');
|
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);
|
$this->mimeType = MimeType::normalize($mimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +39,7 @@ final readonly class PreviewSource
|
|||||||
{
|
{
|
||||||
$resource = ($this->resourceFactory)();
|
$resource = ($this->resourceFactory)();
|
||||||
if (!$resource instanceof BinaryResource) {
|
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;
|
return $resource;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,17 +11,9 @@ use KTXF\Resource\Provider\ProviderInterface;
|
|||||||
|
|
||||||
interface PreviewProviderInterface extends ProviderInterface
|
interface PreviewProviderInterface extends ProviderInterface
|
||||||
{
|
{
|
||||||
/** @return list<string> Exact MIME types or type wildcards such as image/*. */
|
|
||||||
public function supportedMimeTypes(): array;
|
|
||||||
|
|
||||||
/** @return list<string> */
|
|
||||||
public function outputMimeTypes(): array;
|
|
||||||
|
|
||||||
public function supports(string $sourceMimeType, PreviewRequest $request): bool;
|
public function supports(string $sourceMimeType, PreviewRequest $request): bool;
|
||||||
|
|
||||||
public function generate(PreviewSource $source, PreviewRequest $request): PreviewResult;
|
public function generate(PreviewSource $source, PreviewRequest $request): PreviewResult;
|
||||||
|
|
||||||
public function generatorVersion(): string;
|
|
||||||
|
|
||||||
public function priority(): int;
|
public function priority(): int;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXF\Preview;
|
|
||||||
|
|
||||||
final class UnsupportedPreviewException extends PreviewException
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ namespace KTXT\Unit\Preview;
|
|||||||
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use KTXC\Models\Tenant\TenantConfiguration;
|
use KTXC\Models\Tenant\TenantConfiguration;
|
||||||
use KTXC\Models\Tenant\TenantPreviewGeneration;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
@@ -19,10 +18,7 @@ final class PreviewConfigurationTest extends TestCase
|
|||||||
|
|
||||||
self::assertTrue($preview->enabled());
|
self::assertTrue($preview->enabled());
|
||||||
self::assertSame('previews', $preview->store());
|
self::assertSame('previews', $preview->store());
|
||||||
self::assertSame(TenantPreviewGeneration::MODE_EAGER_WITH_LAZY_FALLBACK, $preview->generation()->mode());
|
self::assertSame(26214400, $preview->maxSourceSize());
|
||||||
self::assertSame(104857600, $preview->generation()->maxSourceSize());
|
|
||||||
self::assertSame(30, $preview->generation()->timeoutSeconds());
|
|
||||||
self::assertSame(3600, $preview->generation()->retryDelaySeconds());
|
|
||||||
self::assertSame(640, $preview->variants()->variant('popover')?->width);
|
self::assertSame(640, $preview->variants()->variant('popover')?->width);
|
||||||
self::assertSame(440, $preview->variants()->variant('popover')?->height);
|
self::assertSame(440, $preview->variants()->variant('popover')?->height);
|
||||||
self::assertSame('image/webp', $preview->variants()->variant('popover')?->format);
|
self::assertSame('image/webp', $preview->variants()->variant('popover')?->format);
|
||||||
@@ -36,12 +32,7 @@ final class PreviewConfigurationTest extends TestCase
|
|||||||
$data = [
|
$data = [
|
||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
'store' => 'generated-content',
|
'store' => 'generated-content',
|
||||||
'generation' => [
|
|
||||||
'mode' => TenantPreviewGeneration::MODE_LAZY,
|
|
||||||
'maxSourceSize' => 1024,
|
'maxSourceSize' => 1024,
|
||||||
'timeoutSeconds' => 5,
|
|
||||||
'retryDelaySeconds' => 60,
|
|
||||||
],
|
|
||||||
'variants' => [
|
'variants' => [
|
||||||
'popover' => [
|
'popover' => [
|
||||||
'width' => 800,
|
'width' => 800,
|
||||||
@@ -55,7 +46,7 @@ final class PreviewConfigurationTest extends TestCase
|
|||||||
$configuration = (new TenantConfiguration())->jsonDeserialize(['preview' => $data]);
|
$configuration = (new TenantConfiguration())->jsonDeserialize(['preview' => $data]);
|
||||||
|
|
||||||
self::assertSame('generated-content', $configuration->preview()->store());
|
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(['popover'], array_keys($configuration->preview()->variants()->all()));
|
||||||
self::assertSame('image/png', $configuration->preview()->variants()->variant('popover')?->format);
|
self::assertSame('image/png', $configuration->preview()->variants()->variant('popover')?->format);
|
||||||
$data['variants']['popover']['format'] = 'image/png';
|
$data['variants']['popover']['format'] = 'image/png';
|
||||||
@@ -96,8 +87,7 @@ final class PreviewConfigurationTest extends TestCase
|
|||||||
public static function invalidConfigurations(): iterable
|
public static function invalidConfigurations(): iterable
|
||||||
{
|
{
|
||||||
yield 'invalid store' => [['store' => '../previews']];
|
yield 'invalid store' => [['store' => '../previews']];
|
||||||
yield 'unknown generation mode' => [['generation' => ['mode' => 'eager']]];
|
yield 'zero source limit' => [['maxSourceSize' => 0]];
|
||||||
yield 'zero source limit' => [['generation' => ['maxSourceSize' => 0]]];
|
|
||||||
yield 'invalid variant name' => [['variants' => ['../popover' => []]]];
|
yield 'invalid variant name' => [['variants' => ['../popover' => []]]];
|
||||||
yield 'invalid dimensions' => [['variants' => ['popover' => ['width' => 0]]]];
|
yield 'invalid dimensions' => [['variants' => ['popover' => ['width' => 0]]]];
|
||||||
yield 'invalid output MIME' => [['variants' => ['popover' => ['format' => 'webp']]]];
|
yield 'invalid output MIME' => [['variants' => ['popover' => ['format' => 'webp']]]];
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace KTXT\Unit\Preview;
|
namespace KTXT\Unit\Preview;
|
||||||
|
|
||||||
use KTXF\Preview\MimeType;
|
use KTXF\Preview\MimeType;
|
||||||
use KTXF\Preview\PreviewException;
|
use KTXF\Preview\PreviewGenerationException;
|
||||||
use KTXF\Preview\PreviewRequest;
|
use KTXF\Preview\PreviewRequest;
|
||||||
use KTXF\Preview\PreviewResult;
|
use KTXF\Preview\PreviewResult;
|
||||||
use KTXF\Preview\PreviewSource;
|
use KTXF\Preview\PreviewSource;
|
||||||
@@ -26,28 +26,17 @@ final class PreviewContractsTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function normalizesMimeTypesAndMatchesWildcards(): void
|
public function normalizesMimeTypes(): void
|
||||||
{
|
{
|
||||||
self::assertSame('image/jpeg', MimeType::normalize(' Image/JPEG; charset=binary '));
|
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]
|
#[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([
|
self::assertSame('image/webp', $request->preferredMimeType);
|
||||||
'variant' => 'popover',
|
|
||||||
'maxWidth' => 640,
|
|
||||||
'maxHeight' => 440,
|
|
||||||
'preferredMimeType' => 'image/webp',
|
|
||||||
'quality' => 80,
|
|
||||||
'page' => 1,
|
|
||||||
'timestamp' => null,
|
|
||||||
], $request->normalized());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -75,7 +64,7 @@ final class PreviewContractsTest extends TestCase
|
|||||||
public function sourceRejectsAnInvalidFactoryResult(): void
|
public function sourceRejectsAnInvalidFactoryResult(): void
|
||||||
{
|
{
|
||||||
$source = new PreviewSource('document', 'entity', 'revision', 'image/jpeg', 1, fn() => 'invalid');
|
$source = new PreviewSource('document', 'entity', 'revision', 'image/jpeg', 1, fn() => 'invalid');
|
||||||
$this->expectException(PreviewException::class);
|
$this->expectException(PreviewGenerationException::class);
|
||||||
$source->open();
|
$source->open();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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