Compare commits

..

12 Commits

Author SHA1 Message Date
Sebastian ae26169f37 chore(deps): update dependency symfony/console to v7.4.18
Build Test / build (pull_request) Successful in 31s
JS Unit Tests / test (pull_request) Successful in 21s
PHP Unit Tests / test (pull_request) Successful in 55s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m15s
2026-08-31 03:03:06 +00:00
Sebastian a0093c9547 feat: implement 3 variants
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-29 19:10:48 -04:00
Sebastian f39ad1804d feat: implement preview manager
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-29 18:42:30 -04:00
Sebastian eaea5f4c83 feat: implement preview configuration
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-29 07:53:14 -04:00
Sebastian 30a8eddf06 feat(preview): add shared preview provider contracts
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-28 23:51:29 -04:00
Sebastian 96fbdbab90 feat(core): add system store write conflict handling
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-28 23:28:06 -04:00
Sebastian 545a776aeb feat(core): add CLI management for tenant system stores
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-28 20:56:17 -04:00
Sebastian e137de1183 feat(core): implement tenant-scoped system store manager
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-28 20:47:27 -04:00
Sebastian a1413db12f feat(core): add tenant system store contracts and configuration
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-28 20:39:00 -04:00
Sebastian f0598412f3 feat(core): add system store service interface
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-28 20:25:58 -04:00
Sebastian 52166feca2 refactor(core): centralize reserved system user identity
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-28 19:36:43 -04:00
Sebastian 964128e051 docs: document entityModify()'s authoritative-identifier contract
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-25 22:46:29 -04:00
42 changed files with 2566 additions and 16 deletions
Generated
+7 -7
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "64a8cb19951e001db156d86213bf00bc",
"content-hash": "25e4604f76b54a60904f1517dd3b210d",
"packages": [
{
"name": "laravel/serializable-closure",
@@ -606,16 +606,16 @@
},
{
"name": "symfony/console",
"version": "v7.4.17",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca"
"reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/962e18f09ebe68a49039b4c82fc0ea4871824fca",
"reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca",
"url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0",
"reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0",
"shasum": ""
},
"require": {
@@ -680,7 +680,7 @@
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v7.4.17"
"source": "https://github.com/symfony/console/tree/v7.4.18"
},
"funding": [
{
@@ -700,7 +700,7 @@
"type": "tidelift"
}
],
"time": "2026-08-21T12:09:28+00:00"
"time": "2026-08-25T14:18:37+00:00"
},
{
"name": "symfony/deprecation-contracts",
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\SystemStore\SystemStoreConfigurationService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'tenant:store:list', description: 'List logical stores configured for a tenant')]
class TenantStoreListCommand extends Command
{
public function __construct(private readonly SystemStoreConfigurationService $stores)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = (string) $input->getArgument('tenant');
try {
$stores = $this->stores->list($tenant);
if ($stores === []) {
$io->text("No logical stores configured for tenant '{$tenant}'.");
return Command::SUCCESS;
}
$rows = [];
foreach ($stores as $name => $store) {
$rows[] = [$name, $store->provider, (string) $store->service, $store->namespace];
}
$io->table(['Name', 'Provider', 'Service', 'Namespace'], $rows);
return Command::SUCCESS;
} catch (\Throwable $error) {
$io->error('Failed to list tenant stores: ' . $error->getMessage());
return Command::FAILURE;
}
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\SystemStore\SystemStoreConfigurationService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'tenant:store:remove', description: 'Remove a logical store from a tenant')]
class TenantStoreRemoveCommand extends Command
{
public function __construct(
private readonly SystemStoreConfigurationService $stores,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('name', InputArgument::REQUIRED, 'Logical store name');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = (string) $input->getArgument('tenant');
$name = (string) $input->getArgument('name');
try {
if (!$this->stores->remove($tenant, $name)) {
$io->warning("Logical store '{$name}' was not configured for tenant '{$tenant}'.");
return Command::SUCCESS;
}
$this->logger->info('Tenant logical store removed via console', compact('tenant', 'name'));
$io->success("Logical store '{$name}' removed from tenant '{$tenant}'.");
return Command::SUCCESS;
} catch (\Throwable $error) {
$this->logger->error('Tenant logical store removal failed', [
'tenant' => $tenant,
'name' => $name,
'error' => $error->getMessage(),
]);
$io->error('Failed to remove tenant store: ' . $error->getMessage());
return Command::FAILURE;
}
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\SystemStore\SystemStoreConfigurationService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'tenant:store:set', description: 'Configure a logical store for a tenant')]
class TenantStoreSetCommand extends Command
{
public function __construct(
private readonly SystemStoreConfigurationService $stores,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('name', InputArgument::REQUIRED, 'Logical store name, such as previews')
->addArgument('provider', InputArgument::REQUIRED, 'System-store provider identifier')
->addArgument('service', InputArgument::REQUIRED, 'System-store service identifier')
->addArgument('namespace', InputArgument::REQUIRED, 'Provider key namespace');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = (string) $input->getArgument('tenant');
$name = (string) $input->getArgument('name');
$provider = (string) $input->getArgument('provider');
$service = (string) $input->getArgument('service');
$namespace = (string) $input->getArgument('namespace');
try {
$this->stores->set($tenant, $name, $provider, $service, $namespace);
$this->logger->info('Tenant logical store configured via console', compact(
'tenant', 'name', 'provider', 'service', 'namespace',
));
$io->success("Logical store '{$name}' configured for tenant '{$tenant}'.");
return Command::SUCCESS;
} catch (\Throwable $error) {
$this->logger->error('Tenant logical store configuration failed', [
'tenant' => $tenant,
'name' => $name,
'error' => $error->getMessage(),
]);
$io->error('Failed to configure tenant store: ' . $error->getMessage());
return Command::FAILURE;
}
}
}
@@ -12,12 +12,16 @@ class TenantConfiguration extends JsonSerializableObject
protected TenantAuthentication $authentication;
protected TenantSecurity $security;
protected TenantFirewall $firewall;
protected TenantStores $stores;
protected TenantPreview $preview;
public function __construct()
{
$this->authentication = new TenantAuthentication();
$this->security = new TenantSecurity();
$this->firewall = new TenantFirewall();
$this->stores = new TenantStores();
$this->preview = new TenantPreview();
}
public function authentication(): TenantAuthentication {
@@ -32,4 +36,12 @@ class TenantConfiguration extends JsonSerializableObject
return $this->firewall;
}
public function stores(): TenantStores {
return $this->stores;
}
public function preview(): TenantPreview {
return $this->preview;
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace KTXC\Models\Tenant;
use InvalidArgumentException;
use KTXF\Json\JsonSerializableObject;
final class TenantPreview extends JsonSerializableObject
{
protected bool $enabled = true;
protected string $store = 'previews';
protected int $maxSourceSize = 26214400;
protected TenantPreviewVariants $variants;
public function __construct()
{
$this->variants = new TenantPreviewVariants();
}
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('enabled', $data)) {
if (!is_bool($data['enabled'])) {
throw new InvalidArgumentException('Preview enabled must be a boolean');
}
$this->enabled = $data['enabled'];
}
if (array_key_exists('store', $data)) {
if (!is_string($data['store']) || preg_match('/^[a-z][a-z0-9-]*$/', $data['store']) !== 1) {
throw new InvalidArgumentException('Preview store must be a logical system-store name');
}
$this->store = $data['store'];
}
if (array_key_exists('maxSourceSize', $data)) {
if (!is_int($data['maxSourceSize']) || $data['maxSourceSize'] < 1) {
throw new InvalidArgumentException('Preview maximum source size must be a positive integer');
}
$this->maxSourceSize = $data['maxSourceSize'];
}
if (array_key_exists('variants', $data)) {
if (!is_array($data['variants'])) {
throw new InvalidArgumentException('Preview variants configuration must be an object');
}
$this->variants->jsonDeserialize($data['variants']);
}
if ($this->enabled && $this->variants->all() === []) {
throw new InvalidArgumentException('At least one preview variant is required when previews are enabled');
}
return $this;
}
public function enabled(): bool
{
return $this->enabled;
}
public function store(): string
{
return $this->store;
}
public function maxSourceSize(): int
{
return $this->maxSourceSize;
}
public function variants(): TenantPreviewVariants
{
return $this->variants;
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace KTXC\Models\Tenant;
use InvalidArgumentException;
use KTXF\Preview\MimeType;
final readonly class TenantPreviewVariant
{
public string $format;
public function __construct(
public int $width,
public int $height,
string $format,
public int $quality,
) {
if ($this->width < 1 || $this->height < 1) {
throw new InvalidArgumentException('Preview variant dimensions must be positive');
}
if ($this->quality < 1 || $this->quality > 100) {
throw new InvalidArgumentException('Preview variant quality must be between 1 and 100');
}
$this->format = MimeType::normalize($format);
}
public static function fromArray(array $data, ?self $defaults = null): self
{
$width = $data['width'] ?? $defaults?->width;
$height = $data['height'] ?? $defaults?->height;
$format = $data['format'] ?? $defaults?->format;
$quality = $data['quality'] ?? $defaults?->quality;
if (!is_int($width) || !is_int($height) || !is_string($format) || !is_int($quality)) {
throw new InvalidArgumentException('Preview variants require integer dimensions and quality plus a MIME format');
}
return new self($width, $height, $format, $quality);
}
/** @return array{width:int,height:int,format:string,quality:int} */
public function toArray(): array
{
return [
'width' => $this->width,
'height' => $this->height,
'format' => $this->format,
'quality' => $this->quality,
];
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace KTXC\Models\Tenant;
use InvalidArgumentException;
use KTXF\Json\JsonSerializableObject;
final class TenantPreviewVariants extends JsonSerializableObject
{
/** @var array<string, TenantPreviewVariant> */
private array $entries;
public function __construct()
{
$this->entries = self::defaults();
}
public function jsonDeserialize(array|string $data): static
{
if (is_string($data)) {
$data = json_decode($data, true, flags: JSON_THROW_ON_ERROR);
}
$defaults = self::defaults();
$entries = [];
foreach ($data as $name => $variant) {
if (!is_string($name) || preg_match('/^[a-z][a-z0-9-]*$/', $name) !== 1) {
throw new InvalidArgumentException('Invalid preview variant name');
}
if (!is_array($variant)) {
throw new InvalidArgumentException('Preview variant configuration must be an object');
}
$entries[$name] = TenantPreviewVariant::fromArray($variant, $defaults[$name] ?? null);
}
$this->entries = $entries;
return $this;
}
public function variant(string $name): ?TenantPreviewVariant
{
return $this->entries[$name] ?? null;
}
/** @return array<string, TenantPreviewVariant> */
public function all(): array
{
return $this->entries;
}
public function jsonSerialize(): array
{
return array_map(
static fn(TenantPreviewVariant $variant): array => $variant->toArray(),
$this->entries,
);
}
/** @return array<string, TenantPreviewVariant> */
private static function defaults(): array
{
return [
'thumbnail' => new TenantPreviewVariant(128, 128, 'image/webp', 70),
'inline' => new TenantPreviewVariant(640, 640, 'image/webp', 80),
'fullscreen' => new TenantPreviewVariant(2560, 2560, 'image/webp', 90),
];
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace KTXC\Models\Tenant;
use InvalidArgumentException;
use KTXF\Json\JsonSerializableObject;
use KTXF\SystemStore\StoreReference;
/**
* Logical system stores configured for a tenant.
*/
final class TenantStores extends JsonSerializableObject
{
/** @var array<string, StoreReference> */
private array $entries = [];
public function jsonDeserialize(array|string $data): static
{
if (is_string($data)) {
$data = json_decode($data, true);
}
$this->entries = [];
foreach ($data as $name => $store) {
if (!is_string($name) || preg_match('/^[a-z][a-z0-9-]*$/', $name) !== 1) {
throw new InvalidArgumentException('Invalid logical system-store name');
}
if (!is_array($store)) {
throw new InvalidArgumentException('Invalid tenant store configuration entry');
}
$this->entries[$name] = StoreReference::fromArray($store);
}
return $this;
}
public function jsonSerialize(): array
{
return array_map(
static fn(StoreReference $store): array => $store->toArray(),
$this->entries,
);
}
public function store(string $name): ?StoreReference
{
return $this->entries[$name] ?? null;
}
/** @return array<string, StoreReference> */
public function all(): array
{
return $this->entries;
}
}
+3
View File
@@ -229,6 +229,9 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
\KTXC\Console\Tenant\TenantListCommand::class,
\KTXC\Console\Tenant\TenantDeleteCommand::class,
\KTXC\Console\Tenant\TenantAuthEnableCommand::class,
\KTXC\Console\Tenant\TenantStoreListCommand::class,
\KTXC\Console\Tenant\TenantStoreSetCommand::class,
\KTXC\Console\Tenant\TenantStoreRemoveCommand::class,
\KTXC\Console\User\UserCreateCommand::class,
\KTXC\Console\User\UserListCommand::class,
\KTXC\Console\User\UserDeleteCommand::class,
+19
View File
@@ -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,
) {
}
}
+249
View File
@@ -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 = 'inline',
): ?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'];
}
}
+32
View File
@@ -77,6 +77,38 @@ class TenantStore
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]);
}
/**
* Atomically creates or replaces one logical store reference.
*
* @param array{provider: string, service: string|int, namespace: string} $reference
*/
public function storeConfigurationStore(string $identifier, string $name, array $reference): bool
{
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
['identifier' => $identifier],
['$set' => ["configuration.stores.{$name}" => $reference]],
);
return $result->getMatchedCount() > 0;
}
/**
* Atomically removes one logical store reference.
*/
public function removeConfigurationStore(string $identifier, string $name): ?bool
{
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
['identifier' => $identifier],
['$unset' => ["configuration.stores.{$name}" => '']],
);
if ($result->getMatchedCount() === 0) {
return null;
}
return $result->getModifiedCount() > 0;
}
// =========================================================================
// Settings Operations
// =========================================================================
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace KTXC\SystemStore;
use KTXC\Stores\TenantStore;
use KTXF\SystemStore\StoreReference;
use KTXF\SystemStore\SystemStoreException;
/**
* Superuser-facing configuration operations for tenant logical stores.
*/
class SystemStoreConfigurationService
{
public function __construct(private readonly TenantStore $tenants)
{
}
/** @return array<string, StoreReference> */
public function list(string $tenantId): array
{
$tenant = $this->tenants->fetch($tenantId);
if ($tenant === null) {
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
}
return $tenant->getConfiguration()->stores()->all();
}
public function set(
string $tenantId,
string $name,
string $provider,
string|int $service,
string $namespace,
): StoreReference {
self::validateName($name);
$reference = new StoreReference($provider, $service, $namespace);
if (!$this->tenants->storeConfigurationStore($tenantId, $name, $reference->toArray())) {
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
}
return $reference;
}
public function remove(string $tenantId, string $name): bool
{
self::validateName($name);
$removed = $this->tenants->removeConfigurationStore($tenantId, $name);
if ($removed === null) {
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
}
return $removed;
}
private static function validateName(string $name): void
{
if (preg_match('/^[a-z][a-z0-9-]*$/', $name) !== 1) {
throw new \InvalidArgumentException('Logical store names must use lowercase letters, numbers, and hyphens');
}
}
}
+167
View File
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace KTXC\SystemStore;
use KTXC\Resource\ProviderManager;
use KTXC\Service\TenantService;
use KTXF\Resource\BinaryResource;
use KTXF\Resource\Provider\ProviderInterface;
use KTXF\Resource\SystemIdentity;
use KTXF\SystemStore\BlobInfo;
use KTXF\SystemStore\InvalidKeyException;
use KTXF\SystemStore\Provider\ProviderBaseInterface;
use KTXF\SystemStore\Service\SystemStoreServiceInterface;
use KTXF\SystemStore\StoreReference;
use KTXF\SystemStore\SystemStoreException;
use KTXF\SystemStore\SystemStoreManagerInterface;
use KTXF\SystemStore\WriteCondition;
final readonly class SystemStoreManager implements SystemStoreManagerInterface
{
public function __construct(
private TenantService $tenants,
private ProviderManager $providers,
) {
}
public function stat(string $tenantId, string $store, string $key): ?BlobInfo
{
[$service, $reference] = $this->resolve($tenantId, $store);
$info = $service->stat($this->qualify($reference, $key));
return $info === null ? null : $this->logicalInfo($info, $reference);
}
public function read(string $tenantId, string $store, string $key): ?BinaryResource
{
[$service, $reference] = $this->resolve($tenantId, $store);
return $service->read($this->qualify($reference, $key));
}
public function write(
string $tenantId,
string $store,
string $key,
BinaryResource $content,
array $metadata = [],
?WriteCondition $condition = null,
): BlobInfo {
[$service, $reference] = $this->resolve($tenantId, $store);
$info = $service->write(
$this->qualify($reference, $key),
$content,
$metadata,
$condition,
);
return $this->logicalInfo($info, $reference);
}
public function delete(
string $tenantId,
string $store,
string $key,
?WriteCondition $condition = null,
): bool {
[$service, $reference] = $this->resolve($tenantId, $store);
return $service->delete($this->qualify($reference, $key), $condition);
}
public function list(string $tenantId, string $store, string $prefix = ''): iterable
{
[$service, $reference] = $this->resolve($tenantId, $store);
$physicalPrefix = $reference->namespace . '/';
if ($prefix !== '') {
$physicalPrefix .= $this->normalizeKey($prefix, true);
}
return $this->logicalItems($service->list($physicalPrefix), $reference);
}
/** @return array{SystemStoreServiceInterface, StoreReference} */
private function resolve(string $tenantId, string $store): array
{
$tenant = $this->tenants->fetchById($tenantId);
if ($tenant === null) {
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
}
$reference = $tenant->getConfiguration()->stores()->store($store);
if ($reference === null) {
throw new SystemStoreException("System store '{$store}' is not configured for tenant '{$tenantId}'");
}
$provider = $this->providers->resolve(ProviderInterface::TYPE_SYSTEM_STORE, $reference->provider);
if (!$provider instanceof ProviderBaseInterface) {
throw new SystemStoreException("System-store provider '{$reference->provider}' is unavailable or incompatible");
}
$service = $provider->serviceFetch($tenantId, SystemIdentity::USER, $reference->service);
if (!$service instanceof SystemStoreServiceInterface) {
throw new SystemStoreException("System-store service '{$reference->service}' is unavailable or incompatible");
}
return [$service, $reference];
}
private function qualify(StoreReference $reference, string $key): string
{
return $reference->namespace . '/' . $this->normalizeKey($key);
}
private function normalizeKey(string $key, bool $allowTrailingSlash = false): string
{
if (
$key === ''
|| str_starts_with($key, '/')
|| (!$allowTrailingSlash && str_ends_with($key, '/'))
|| str_contains($key, '\\')
|| str_contains($key, "\0")
) {
throw new InvalidKeyException('System-store keys must be non-empty normalized relative keys');
}
$segments = explode('/', $key);
if ($allowTrailingSlash && end($segments) === '') {
array_pop($segments);
}
if (in_array('', $segments, true) || in_array('.', $segments, true) || in_array('..', $segments, true)) {
throw new InvalidKeyException('System-store keys cannot contain empty or traversal segments');
}
return $key;
}
private function logicalInfo(BlobInfo $info, StoreReference $reference): BlobInfo
{
$prefix = $reference->namespace . '/';
if (!str_starts_with($info->key, $prefix)) {
throw new SystemStoreException('System-store provider returned a blob outside the configured namespace');
}
return new BlobInfo(
key: substr($info->key, strlen($prefix)),
mimeType: $info->mimeType,
size: $info->size,
etag: $info->etag,
modifiedAt: $info->modifiedAt,
attributes: $info->attributes,
);
}
/**
* @param iterable<BlobInfo> $items
* @return iterable<BlobInfo>
*/
private function logicalItems(iterable $items, StoreReference $reference): iterable
{
foreach ($items as $item) {
if (!$item instanceof BlobInfo) {
throw new SystemStoreException('System-store provider returned invalid listing metadata');
}
yield $this->logicalInfo($item, $reference);
}
}
}
@@ -11,6 +11,7 @@ namespace KTXF\Mail\Provider;
use KTXF\Mail\Service\ServiceBaseInterface;
use KTXF\Resource\Provider\ResourceProviderBaseInterface;
use KTXF\Resource\SystemIdentity;
/**
* Provider Base Interface
@@ -24,15 +25,9 @@ interface ProviderBaseInterface extends ResourceProviderBaseInterface{
public const JSON_TYPE = 'mail:provider';
/**
* Reserved user identifier for system-owned mail services
*
* Services stored under this user id belong to the tenant itself rather
* than any real user (e.g. accounts used to deliver system-generated
* messages such as verification codes and password resets).
*
* @since 2026.07.01
* @deprecated Use SystemIdentity::USER.
*/
public const USER_SYSTEM = 'system';
public const USER_SYSTEM = SystemIdentity::USER;
/**
* Finds a service that handles a specific email address
@@ -56,12 +56,19 @@ interface ServiceEntityMutableInterface {
/**
* Modifies an existing entity
*
* The returned entity's identifier is authoritative and may differ from
* the target identifier. Providers without in-place update support may
* implement this as an append-and-replace (or equivalent create-then-
* destroy) operation, in which case the target identifier is superseded
* and no longer resolvable; callers must persist the returned identifier
* rather than assume the target remains valid.
*
* @since 2025.05.01
*
* @param EntityIdentifier $target Target entity identifier
* @param MessagePropertiesMutableInterface $properties Entity properties to update
*
* @return EntityBaseInterface Modified entity
* @return EntityBaseInterface Modified entity, with its own authoritative identifier
*/
public function entityModify(EntityIdentifier $target, MessagePropertiesMutableInterface $properties): EntityBaseInterface;
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace KTXF\Preview;
use InvalidArgumentException;
final class MimeType
{
public static function normalize(string $mimeType): string
{
$mimeType = strtolower(trim(explode(';', $mimeType, 2)[0]));
if (preg_match('~^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+$~', $mimeType) !== 1) {
throw new InvalidArgumentException("Invalid MIME type '{$mimeType}'");
}
return $mimeType;
}
public static function matches(string $pattern, string $mimeType): bool
{
$mimeType = self::normalize($mimeType);
$pattern = strtolower(trim($pattern));
if ($pattern === '*/*') {
return true;
}
if (preg_match('~^[a-z0-9!#$&^_.+-]+/\*$~', $pattern) === 1) {
return str_starts_with($mimeType, substr($pattern, 0, -1));
}
return self::normalize($pattern) === $mimeType;
}
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace KTXF\Preview;
use RuntimeException;
final class PreviewGenerationException extends RuntimeException
{
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace KTXF\Preview;
use InvalidArgumentException;
final readonly class PreviewRequest
{
public string $preferredMimeType;
public function __construct(
public int $maxWidth,
public int $maxHeight,
string $preferredMimeType,
public int $quality,
public int $maxSourceSize = 26214400,
) {
if ($this->maxWidth < 1 || $this->maxHeight < 1) {
throw new InvalidArgumentException('Preview dimensions must be positive');
}
if ($this->quality < 1 || $this->quality > 100) {
throw new InvalidArgumentException('Preview quality must be between 1 and 100');
}
if ($this->maxSourceSize < 1) {
throw new InvalidArgumentException('Preview maximum source size must be positive');
}
$this->preferredMimeType = MimeType::normalize($preferredMimeType);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace KTXF\Preview;
use InvalidArgumentException;
use KTXF\Resource\BinaryResource;
final readonly class PreviewResult
{
public function __construct(
public BinaryResource $resource,
public ?int $width = null,
public ?int $height = null,
) {
MimeType::normalize($this->resource->mimeType());
if ($this->width !== null && $this->width < 1) {
throw new InvalidArgumentException('Preview result width must be positive');
}
if ($this->height !== null && $this->height < 1) {
throw new InvalidArgumentException('Preview result height must be positive');
}
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace KTXF\Preview;
use Closure;
use InvalidArgumentException;
use KTXF\Resource\BinaryResource;
final readonly class PreviewSource
{
public string $mimeType;
/**
* @param Closure(): BinaryResource $resourceFactory
*/
public function __construct(
public string $sourceType,
public string $identity,
public string $signature,
string $mimeType,
public ?int $size,
public Closure $resourceFactory,
) {
if (preg_match('/^[a-z][a-z0-9-]*$/', $this->sourceType) !== 1) {
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) {
throw new InvalidArgumentException('Preview source size cannot be negative');
}
$this->mimeType = MimeType::normalize($mimeType);
}
public function open(): BinaryResource
{
$resource = ($this->resourceFactory)();
if (!$resource instanceof BinaryResource) {
throw new PreviewGenerationException('Preview source factory did not return a BinaryResource');
}
return $resource;
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace KTXF\Preview\Provider;
use KTXF\Preview\PreviewRequest;
use KTXF\Preview\PreviewResult;
use KTXF\Preview\PreviewSource;
use KTXF\Resource\Provider\ProviderInterface;
interface PreviewProviderInterface extends ProviderInterface
{
public function supports(string $sourceMimeType, PreviewRequest $request): bool;
public function generate(PreviewSource $source, PreviewRequest $request): PreviewResult;
public function priority(): int;
}
@@ -15,6 +15,8 @@ interface ProviderInterface
public const TYPE_PEOPLE = 'people';
public const TYPE_CHRONO = 'chrono';
public const TYPE_MAIL = 'mail';
public const TYPE_SYSTEM_STORE = 'system-store';
public const TYPE_PREVIEW = 'preview';
/**
* Provider type (e.g., 'authentication', 'document', 'people', 'chrono', 'mail')
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace KTXF\Resource;
/**
* Reserved identities used by tenant-scoped internal services.
*/
final class SystemIdentity
{
/**
* User identifier for resources owned by the tenant rather than a person.
*/
public const USER = 'system';
private function __construct()
{
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace KTXF\SystemStore;
use DateTimeImmutable;
use InvalidArgumentException;
/**
* Provider-neutral information about a blob in a system store.
*/
final readonly class BlobInfo
{
public function __construct(
public string $key,
public string $mimeType,
public int $size,
public string $etag,
public DateTimeImmutable $modifiedAt,
public array $attributes = [],
) {
if ($this->key === '') {
throw new InvalidArgumentException('Blob key cannot be empty');
}
if ($this->mimeType === '') {
throw new InvalidArgumentException('Blob MIME type cannot be empty');
}
if ($this->size < 0) {
throw new InvalidArgumentException('Blob size cannot be negative');
}
if ($this->etag === '') {
throw new InvalidArgumentException('Blob ETag cannot be empty');
}
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace KTXF\SystemStore;
use InvalidArgumentException;
/**
* Raised when a logical system-store key is not safely normalized.
*/
final class InvalidKeyException extends InvalidArgumentException
{
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace KTXF\SystemStore\Provider;
use KTXF\Resource\Provider\ResourceProviderBaseInterface;
/**
* Provider for tenant-scoped internal storage services.
*/
interface ProviderBaseInterface extends ResourceProviderBaseInterface
{
public const JSON_TYPE = 'system-store:provider';
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace KTXF\SystemStore\Service;
use KTXF\Resource\BinaryResource;
use KTXF\Resource\Provider\ResourceServiceBaseInterface;
use KTXF\SystemStore\BlobInfo;
use KTXF\SystemStore\WriteCondition;
/**
* Basic key-addressed storage contract for tenant-owned system data.
*/
interface SystemStoreServiceInterface extends ResourceServiceBaseInterface
{
public const JSON_TYPE = 'system-store:service';
public function stat(string $key): ?BlobInfo;
public function read(string $key): ?BinaryResource;
public function write(
string $key,
BinaryResource $content,
array $metadata = [],
?WriteCondition $condition = null,
): BlobInfo;
public function delete(
string $key,
?WriteCondition $condition = null,
): bool;
/**
* Iterates stored metadata lazily. Implementations must keep memory use
* bounded and handle any backend pagination internally.
*
* @return iterable<BlobInfo>
*/
public function list(string $prefix = ''): iterable;
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace KTXF\SystemStore;
use InvalidArgumentException;
/**
* Provider and service selected for one logical tenant system store.
*/
final readonly class StoreReference
{
public function __construct(
public string $provider,
public string|int $service,
public string $namespace,
) {
if ($this->provider === '') {
throw new InvalidArgumentException('System-store provider cannot be empty');
}
if ($this->service === '') {
throw new InvalidArgumentException('System-store service cannot be empty');
}
if (
$this->namespace === ''
|| str_starts_with($this->namespace, '/')
|| str_ends_with($this->namespace, '/')
|| str_contains($this->namespace, '\\')
|| str_contains($this->namespace, "\0")
|| in_array('', explode('/', $this->namespace), true)
|| in_array('.', explode('/', $this->namespace), true)
|| in_array('..', explode('/', $this->namespace), true)
) {
throw new InvalidArgumentException('System-store namespace must be a normalized relative key');
}
}
public static function fromArray(array $data): self
{
$provider = $data['provider'] ?? null;
$service = $data['service'] ?? null;
$namespace = $data['namespace'] ?? null;
if (!is_string($provider) || (!is_string($service) && !is_int($service)) || !is_string($namespace)) {
throw new InvalidArgumentException('Invalid system-store reference');
}
return new self($provider, $service, $namespace);
}
public function toArray(): array
{
return [
'provider' => $this->provider,
'service' => $this->service,
'namespace' => $this->namespace,
];
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace KTXF\SystemStore;
use RuntimeException;
/**
* Raised when a tenant system store cannot be resolved or trusted.
*/
class SystemStoreException extends RuntimeException
{
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace KTXF\SystemStore;
use KTXF\Resource\BinaryResource;
/**
* Tenant-scoped access to logical internal stores.
*/
interface SystemStoreManagerInterface
{
public function stat(string $tenantId, string $store, string $key): ?BlobInfo;
public function read(string $tenantId, string $store, string $key): ?BinaryResource;
public function write(
string $tenantId,
string $store,
string $key,
BinaryResource $content,
array $metadata = [],
?WriteCondition $condition = null,
): BlobInfo;
public function delete(
string $tenantId,
string $store,
string $key,
?WriteCondition $condition = null,
): bool;
/**
* @return iterable<BlobInfo>
*/
public function list(string $tenantId, string $store, string $prefix = ''): iterable;
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace KTXF\SystemStore;
use InvalidArgumentException;
/**
* Optional precondition applied atomically by a system-store write or delete.
*/
final readonly class WriteCondition
{
public const NONE = 'none';
public const IF_ABSENT = 'if-absent';
public const IF_MATCH = 'if-match';
private function __construct(
public string $mode,
public ?string $etag = null,
) {
if (!in_array($this->mode, [self::NONE, self::IF_ABSENT, self::IF_MATCH], true)) {
throw new InvalidArgumentException('Invalid system-store write condition: ' . $this->mode);
}
if ($this->mode === self::IF_MATCH && ($this->etag === null || $this->etag === '')) {
throw new InvalidArgumentException('An ETag is required for an if-match condition');
}
if ($this->mode !== self::IF_MATCH && $this->etag !== null) {
throw new InvalidArgumentException('An ETag is only valid for an if-match condition');
}
}
public static function none(): self
{
return new self(self::NONE);
}
public static function ifAbsent(): self
{
return new self(self::IF_ABSENT);
}
public static function ifMatch(string $etag): self
{
return new self(self::IF_MATCH, $etag);
}
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace KTXF\SystemStore;
/** Raised when an atomic write or delete precondition is not satisfied. */
final class WriteConflictException extends SystemStoreException
{
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Console\Tenant;
use KTXC\Console\Tenant\TenantStoreListCommand;
use KTXC\Console\Tenant\TenantStoreRemoveCommand;
use KTXC\Console\Tenant\TenantStoreSetCommand;
use KTXC\SystemStore\SystemStoreConfigurationService;
use KTXF\SystemStore\StoreReference;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
final class TenantStoreCommandsTest extends TestCase
{
#[Test]
public function listsTenantStores(): void
{
$stores = $this->createStub(SystemStoreConfigurationService::class);
$stores->method('list')->willReturn([
'previews' => new StoreReference('local', 'default', 'previews'),
]);
$tester = new CommandTester(new TenantStoreListCommand($stores));
$status = $tester->execute(['tenant' => 'tenant-a']);
self::assertSame(Command::SUCCESS, $status);
self::assertStringContainsString('previews', $tester->getDisplay());
self::assertStringContainsString('local', $tester->getDisplay());
}
#[Test]
public function configuresATenantStore(): void
{
$stores = $this->createMock(SystemStoreConfigurationService::class);
$stores->expects(self::once())
->method('set')
->with('tenant-a', 'previews', 'local', 'default', 'previews')
->willReturn(new StoreReference('local', 'default', 'previews'));
$tester = new CommandTester(new TenantStoreSetCommand($stores, new NullLogger()));
$status = $tester->execute([
'tenant' => 'tenant-a',
'name' => 'previews',
'provider' => 'local',
'service' => 'default',
'namespace' => 'previews',
]);
self::assertSame(Command::SUCCESS, $status);
self::assertStringContainsString('configured', $tester->getDisplay());
}
#[Test]
public function removesATenantStore(): void
{
$stores = $this->createMock(SystemStoreConfigurationService::class);
$stores->expects(self::once())->method('remove')->with('tenant-a', 'previews')->willReturn(true);
$tester = new CommandTester(new TenantStoreRemoveCommand($stores, new NullLogger()));
$status = $tester->execute(['tenant' => 'tenant-a', 'name' => 'previews']);
self::assertSame(Command::SUCCESS, $status);
self::assertStringContainsString('removed', $tester->getDisplay());
}
#[Test]
public function removingAnAbsentStoreIsIdempotent(): void
{
$stores = $this->createStub(SystemStoreConfigurationService::class);
$stores->method('remove')->willReturn(false);
$tester = new CommandTester(new TenantStoreRemoveCommand($stores, new NullLogger()));
$status = $tester->execute(['tenant' => 'tenant-a', 'name' => 'previews']);
self::assertSame(Command::SUCCESS, $status);
self::assertStringContainsString('not configured', $tester->getDisplay());
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Preview;
use InvalidArgumentException;
use KTXC\Models\Tenant\TenantConfiguration;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class PreviewConfigurationTest extends TestCase
{
#[Test]
public function providesSafeDefaultsWhenPreviewConfigurationIsAbsent(): void
{
$preview = (new TenantConfiguration())->preview();
self::assertTrue($preview->enabled());
self::assertSame('previews', $preview->store());
self::assertSame(26214400, $preview->maxSourceSize());
self::assertSame(128, $preview->variants()->variant('thumbnail')?->width);
self::assertSame(128, $preview->variants()->variant('thumbnail')?->height);
self::assertSame(70, $preview->variants()->variant('thumbnail')?->quality);
self::assertSame(640, $preview->variants()->variant('inline')?->width);
self::assertSame(640, $preview->variants()->variant('inline')?->height);
self::assertSame('image/webp', $preview->variants()->variant('inline')?->format);
self::assertSame(80, $preview->variants()->variant('inline')?->quality);
self::assertSame(2560, $preview->variants()->variant('fullscreen')?->width);
self::assertSame(2560, $preview->variants()->variant('fullscreen')?->height);
self::assertSame(90, $preview->variants()->variant('fullscreen')?->quality);
}
#[Test]
public function loadsAndSerializesTenantPreviewConfiguration(): void
{
$data = [
'enabled' => true,
'store' => 'generated-content',
'maxSourceSize' => 1024,
'variants' => [
'inline' => [
'width' => 800,
'height' => 600,
'format' => 'IMAGE/PNG',
'quality' => 90,
],
],
];
$configuration = (new TenantConfiguration())->jsonDeserialize(['preview' => $data]);
self::assertSame('generated-content', $configuration->preview()->store());
self::assertSame(1024, $configuration->preview()->maxSourceSize());
self::assertSame(['inline'], array_keys($configuration->preview()->variants()->all()));
self::assertSame('image/png', $configuration->preview()->variants()->variant('inline')?->format);
$data['variants']['inline']['format'] = 'image/png';
self::assertSame($data, $configuration->jsonSerialize()['preview']);
}
#[Test]
public function mergesKnownVariantDefaultsForPartialOverrides(): void
{
$configuration = (new TenantConfiguration())->jsonDeserialize([
'preview' => [
'variants' => [
'inline' => ['width' => 900],
],
],
]);
$variant = $configuration->preview()->variants()->variant('inline');
self::assertSame(900, $variant?->width);
self::assertSame(640, $variant?->height);
self::assertSame('image/webp', $variant?->format);
self::assertSame(80, $variant?->quality);
self::assertNull($configuration->preview()->variants()->variant('thumbnail'));
}
#[Test]
public function allowsNoVariantsWhenPreviewsAreDisabled(): void
{
$configuration = (new TenantConfiguration())->jsonDeserialize([
'preview' => ['enabled' => false, 'variants' => []],
]);
self::assertFalse($configuration->preview()->enabled());
self::assertSame([], $configuration->preview()->variants()->all());
}
/** @return iterable<string, array{array<string, mixed>}> */
public static function invalidConfigurations(): iterable
{
yield 'invalid store' => [['store' => '../previews']];
yield 'zero source limit' => [['maxSourceSize' => 0]];
yield 'invalid variant name' => [['variants' => ['../inline' => []]]];
yield 'invalid dimensions' => [['variants' => ['inline' => ['width' => 0]]]];
yield 'invalid output MIME' => [['variants' => ['inline' => ['format' => 'webp']]]];
yield 'invalid quality' => [['variants' => ['inline' => ['quality' => 101]]]];
yield 'enabled without variants' => [['enabled' => true, 'variants' => []]];
}
#[Test]
#[\PHPUnit\Framework\Attributes\DataProvider('invalidConfigurations')]
public function rejectsInvalidConfiguration(array $data): void
{
$this->expectException(InvalidArgumentException::class);
(new TenantConfiguration())->jsonDeserialize(['preview' => $data]);
}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Preview;
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\BinaryResource;
use KTXF\Resource\Provider\ProviderInterface;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
final class PreviewContractsTest extends TestCase
{
#[Test]
public function registersThePreviewProviderType(): void
{
self::assertSame('preview', ProviderInterface::TYPE_PREVIEW);
self::assertTrue((new ReflectionClass(PreviewProviderInterface::class))->isSubclassOf(ProviderInterface::class));
}
#[Test]
public function normalizesMimeTypes(): void
{
self::assertSame('image/jpeg', MimeType::normalize(' Image/JPEG; charset=binary '));
}
#[Test]
public function requestNormalizesItsOutputMimeType(): void
{
$request = new PreviewRequest(640, 440, 'IMAGE/WEBP', 80);
self::assertSame('image/webp', $request->preferredMimeType);
}
#[Test]
public function sourceDoesNotOpenUntilRequested(): void
{
$opened = 0;
$source = new PreviewSource(
sourceType: 'document',
identity: 'provider/service/entity',
signature: 'revision-1',
mimeType: 'image/jpeg',
size: 7,
resourceFactory: function () use (&$opened): BinaryResource {
$opened++;
return $this->resource('source');
},
);
self::assertSame(0, $opened);
self::assertInstanceOf(BinaryResource::class, $source->open());
self::assertSame(1, $opened);
}
#[Test]
public function sourceRejectsAnInvalidFactoryResult(): void
{
$source = new PreviewSource('document', 'entity', 'revision', 'image/jpeg', 1, fn() => 'invalid');
$this->expectException(PreviewGenerationException::class);
$source->open();
}
#[Test]
public function resultUsesItsBinaryResourceMimeType(): void
{
$result = new PreviewResult($this->resource('preview'), 320, 200);
self::assertSame('image/webp', $result->resource->mimeType());
self::assertSame(320, $result->width);
self::assertSame(200, $result->height);
}
private function resource(string $content): BinaryResource
{
return new BinaryResource(
'preview.webp',
'image/webp',
(function () use ($content): \Generator { yield $content; })(),
);
}
}
@@ -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, 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}";
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Resource;
use KTXF\Mail\Provider\ProviderBaseInterface;
use KTXF\Resource\SystemIdentity;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class SystemIdentityTest extends TestCase
{
#[Test]
public function exposesTheReservedSystemUser(): void
{
self::assertSame('system', SystemIdentity::USER);
}
#[Test]
public function retainsTheLegacyMailAlias(): void
{
self::assertSame(SystemIdentity::USER, ProviderBaseInterface::USER_SYSTEM);
}
}
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\SystemStore;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Stores\TenantStore;
use KTXC\SystemStore\SystemStoreConfigurationService;
use KTXF\SystemStore\SystemStoreException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class SystemStoreConfigurationServiceTest extends TestCase
{
private TenantStore&MockObject $tenants;
private SystemStoreConfigurationService $configuration;
protected function setUp(): void
{
$this->tenants = $this->createMock(TenantStore::class);
$this->configuration = new SystemStoreConfigurationService($this->tenants);
}
#[Test]
public function listsConfiguredLogicalStores(): void
{
$tenant = (new TenantObject())->setConfiguration(
(new TenantConfiguration())->jsonDeserialize([
'stores' => [
'previews' => [
'provider' => 'local',
'service' => 'default',
'namespace' => 'previews',
],
],
]),
);
$this->tenants->expects(self::once())->method('fetch')->with('tenant-a')->willReturn($tenant);
$stores = $this->configuration->list('tenant-a');
self::assertSame('local', $stores['previews']->provider);
}
#[Test]
public function atomicallyStoresOneLogicalStoreReference(): void
{
$this->tenants->expects(self::once())
->method('storeConfigurationStore')
->with('tenant-a', 'previews', [
'provider' => 'local',
'service' => 'default',
'namespace' => 'previews',
])
->willReturn(true);
$reference = $this->configuration->set(
'tenant-a', 'previews', 'local', 'default', 'previews',
);
self::assertSame('local', $reference->provider);
}
#[Test]
public function reportsAMissingTenantDuringConfiguration(): void
{
$this->tenants->expects(self::once())->method('storeConfigurationStore')->willReturn(false);
$this->expectException(SystemStoreException::class);
$this->configuration->set('missing', 'previews', 'local', 'default', 'previews');
}
#[Test]
public function atomicallyRemovesOneLogicalStoreReference(): void
{
$this->tenants->expects(self::once())
->method('removeConfigurationStore')
->with('tenant-a', 'previews')
->willReturn(true);
self::assertTrue($this->configuration->remove('tenant-a', 'previews'));
}
#[Test]
public function reportsAMissingTenantDuringRemoval(): void
{
$this->tenants->expects(self::once())->method('removeConfigurationStore')->willReturn(null);
$this->expectException(SystemStoreException::class);
$this->configuration->remove('missing', 'previews');
}
#[Test]
public function rejectsUnsafeStoreNamesBeforeBuildingMongoPaths(): void
{
$this->tenants->expects(self::never())->method('storeConfigurationStore');
$this->expectException(\InvalidArgumentException::class);
$this->configuration->set('tenant-a', 'previews.other', 'local', 'default', 'previews');
}
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\SystemStore;
use DateTimeImmutable;
use InvalidArgumentException;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXF\Resource\Provider\ProviderInterface;
use KTXF\Resource\Provider\ResourceProviderBaseInterface;
use KTXF\Resource\Provider\ResourceServiceBaseInterface;
use KTXF\SystemStore\BlobInfo;
use KTXF\SystemStore\Provider\ProviderBaseInterface;
use KTXF\SystemStore\Service\SystemStoreServiceInterface;
use KTXF\SystemStore\StoreReference;
use KTXF\SystemStore\SystemStoreManagerInterface;
use KTXF\SystemStore\WriteCondition;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
final class SystemStoreContractsTest extends TestCase
{
#[Test]
public function registersTheSystemStoreProviderType(): void
{
self::assertSame('system-store', ProviderInterface::TYPE_SYSTEM_STORE);
}
#[Test]
public function providerAndServiceContractsExtendResourceContracts(): void
{
self::assertTrue((new ReflectionClass(ProviderBaseInterface::class))->isSubclassOf(ResourceProviderBaseInterface::class));
self::assertTrue((new ReflectionClass(SystemStoreServiceInterface::class))->isSubclassOf(ResourceServiceBaseInterface::class));
}
#[Test]
public function listingIsAProviderPaginatedIterable(): void
{
$method = (new ReflectionClass(SystemStoreServiceInterface::class))->getMethod('list');
self::assertSame('iterable', (string) $method->getReturnType());
self::assertCount(1, $method->getParameters());
}
#[Test]
public function managerContractExposesTenantScopedStoreOperations(): void
{
$manager = new ReflectionClass(SystemStoreManagerInterface::class);
self::assertSame(
['stat', 'read', 'write', 'delete', 'list'],
array_map(static fn($method): string => $method->getName(), $manager->getMethods()),
);
}
#[Test]
public function createsSupportedWriteConditions(): void
{
self::assertSame(WriteCondition::NONE, WriteCondition::none()->mode);
self::assertSame(WriteCondition::IF_ABSENT, WriteCondition::ifAbsent()->mode);
$condition = WriteCondition::ifMatch('revision-1');
self::assertSame(WriteCondition::IF_MATCH, $condition->mode);
self::assertSame('revision-1', $condition->etag);
}
#[Test]
public function rejectsAnEmptyIfMatchEtag(): void
{
$this->expectException(InvalidArgumentException::class);
WriteCondition::ifMatch('');
}
#[Test]
public function exposesBlobInformation(): void
{
$metadata = new BlobInfo(
key: 'previews/example.webp',
mimeType: 'image/webp',
size: 123,
etag: 'revision-1',
modifiedAt: new DateTimeImmutable('2026-08-28T12:00:00+00:00'),
attributes: ['variant' => 'popover'],
);
self::assertSame('previews/example.webp', $metadata->key);
self::assertSame('image/webp', $metadata->mimeType);
self::assertSame(123, $metadata->size);
self::assertSame('revision-1', $metadata->etag);
self::assertSame(['variant' => 'popover'], $metadata->attributes);
self::assertNotInstanceOf(\JsonSerializable::class, $metadata);
}
#[Test]
public function parsesLogicalStoresFromTenantConfiguration(): void
{
$data = [
'stores' => [
'previews' => [
'provider' => 's3',
'service' => 'preview-storage',
'namespace' => 'previews',
],
],
];
$configuration = (new TenantConfiguration())->jsonDeserialize($data);
self::assertInstanceOf(StoreReference::class, $configuration->stores()->store('previews'));
self::assertSame('s3', $configuration->stores()->store('previews')?->provider);
self::assertSame($data['stores'], $configuration->jsonSerialize()['stores']);
}
#[Test]
public function rejectsInvalidLogicalStoreNames(): void
{
$this->expectException(InvalidArgumentException::class);
(new TenantConfiguration())->jsonDeserialize([
'stores' => [
'../previews' => [
'provider' => 's3',
'service' => 'preview-storage',
'namespace' => 'previews',
],
],
]);
}
}
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\SystemStore;
use DateTimeImmutable;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Resource\ProviderManager;
use KTXC\Service\TenantService;
use KTXC\SystemStore\SystemStoreManager;
use KTXF\Resource\BinaryResource;
use KTXF\Resource\Provider\ProviderInterface;
use KTXF\Resource\SystemIdentity;
use KTXF\SystemStore\BlobInfo;
use KTXF\SystemStore\InvalidKeyException;
use KTXF\SystemStore\Provider\ProviderBaseInterface;
use KTXF\SystemStore\Service\SystemStoreServiceInterface;
use KTXF\SystemStore\SystemStoreException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class SystemStoreManagerTest extends TestCase
{
private TenantService&MockObject $tenants;
private ProviderManager&MockObject $providers;
private ProviderBaseInterface&MockObject $provider;
private SystemStoreServiceInterface&MockObject $service;
private SystemStoreManager $manager;
protected function setUp(): void
{
$configuration = (new TenantConfiguration())->jsonDeserialize([
'stores' => [
'previews' => [
'provider' => 'storage-provider',
'service' => 'preview-storage',
'namespace' => 'tenant-previews',
],
],
]);
$tenant = (new TenantObject())
->setIdentifier('tenant-a')
->setConfiguration($configuration);
$this->tenants = $this->createMock(TenantService::class);
$this->tenants->method('fetchById')->with('tenant-a')->willReturn($tenant);
$this->service = $this->createMock(SystemStoreServiceInterface::class);
$this->provider = $this->createMock(ProviderBaseInterface::class);
$this->provider->method('serviceFetch')
->with('tenant-a', SystemIdentity::USER, 'preview-storage')
->willReturn($this->service);
$this->providers = $this->createMock(ProviderManager::class);
$this->providers->method('resolve')
->with(ProviderInterface::TYPE_SYSTEM_STORE, 'storage-provider')
->willReturn($this->provider);
$this->manager = new SystemStoreManager($this->tenants, $this->providers);
}
#[Test]
public function delegatesStatWithPhysicalNamespaceAndReturnsLogicalKey(): void
{
$this->service->expects(self::once())
->method('stat')
->with('tenant-previews/documents/report.pdf')
->willReturn($this->info('tenant-previews/documents/report.pdf'));
$info = $this->manager->stat('tenant-a', 'previews', 'documents/report.pdf');
self::assertSame('documents/report.pdf', $info?->key);
}
#[Test]
public function delegatesReadDeleteAndWriteWithPhysicalNamespace(): void
{
$resource = $this->resource();
$this->service->expects(self::once())
->method('read')
->with('tenant-previews/image.webp')
->willReturn($resource);
$this->service->expects(self::once())
->method('delete')
->with('tenant-previews/image.webp', null)
->willReturn(true);
$this->service->expects(self::once())
->method('write')
->with('tenant-previews/image.webp', $resource, ['variant' => 'popover'], null)
->willReturn($this->info('tenant-previews/image.webp'));
self::assertSame($resource, $this->manager->read('tenant-a', 'previews', 'image.webp'));
self::assertTrue($this->manager->delete('tenant-a', 'previews', 'image.webp'));
self::assertSame(
'image.webp',
$this->manager->write(
'tenant-a',
'previews',
'image.webp',
$resource,
['variant' => 'popover'],
)->key,
);
}
#[Test]
public function lazilyListsLogicalKeysWithinTheNamespace(): void
{
$this->service->expects(self::once())
->method('list')
->with('tenant-previews/documents/')
->willReturn((function (): iterable {
yield $this->info('tenant-previews/documents/one.webp');
yield $this->info('tenant-previews/documents/two.webp');
})());
$items = iterator_to_array($this->manager->list('tenant-a', 'previews', 'documents/'));
self::assertSame(['documents/one.webp', 'documents/two.webp'], array_column($items, 'key'));
}
#[Test]
public function rejectsTraversalKeys(): void
{
$this->service->expects(self::never())->method('read');
$this->expectException(InvalidKeyException::class);
$this->manager->read('tenant-a', 'previews', '../secret');
}
#[Test]
public function rejectsProviderResultsOutsideTheConfiguredNamespace(): void
{
$this->service->expects(self::once())
->method('stat')
->willReturn($this->info('another-tenant/secret'));
$this->expectException(SystemStoreException::class);
$this->manager->stat('tenant-a', 'previews', 'secret');
}
#[Test]
public function rejectsAnUnconfiguredLogicalStore(): void
{
$this->service->expects(self::never())->method('stat');
$this->expectException(SystemStoreException::class);
$this->expectExceptionMessage("System store 'icons' is not configured");
$this->manager->stat('tenant-a', 'icons', 'logo.svg');
}
private function info(string $key): BlobInfo
{
return new BlobInfo(
key: $key,
mimeType: 'image/webp',
size: 123,
etag: 'revision-1',
modifiedAt: new DateTimeImmutable('2026-08-28T12:00:00+00:00'),
);
}
private function resource(): BinaryResource
{
return new BinaryResource(
'image.webp',
'image/webp',
(function (): \Generator {
yield 'content';
})(),
);
}
}