diff --git a/core/lib/Models/Tenant/TenantConfiguration.php b/core/lib/Models/Tenant/TenantConfiguration.php index b3f76a4..a85ed28 100644 --- a/core/lib/Models/Tenant/TenantConfiguration.php +++ b/core/lib/Models/Tenant/TenantConfiguration.php @@ -13,6 +13,7 @@ class TenantConfiguration extends JsonSerializableObject protected TenantSecurity $security; protected TenantFirewall $firewall; protected TenantStores $stores; + protected TenantPreview $preview; public function __construct() { @@ -20,6 +21,7 @@ class TenantConfiguration extends JsonSerializableObject $this->security = new TenantSecurity(); $this->firewall = new TenantFirewall(); $this->stores = new TenantStores(); + $this->preview = new TenantPreview(); } public function authentication(): TenantAuthentication { @@ -38,4 +40,8 @@ class TenantConfiguration extends JsonSerializableObject return $this->stores; } + public function preview(): TenantPreview { + return $this->preview; + } + } diff --git a/core/lib/Models/Tenant/TenantPreview.php b/core/lib/Models/Tenant/TenantPreview.php new file mode 100644 index 0000000..77efb4b --- /dev/null +++ b/core/lib/Models/Tenant/TenantPreview.php @@ -0,0 +1,83 @@ +generation = new TenantPreviewGeneration(); + $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('generation', $data)) { + if (!is_array($data['generation'])) { + throw new InvalidArgumentException('Preview generation configuration must be an object'); + } + $this->generation->jsonDeserialize($data['generation']); + } + + 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 generation(): TenantPreviewGeneration + { + return $this->generation; + } + + public function variants(): TenantPreviewVariants + { + return $this->variants; + } +} diff --git a/core/lib/Models/Tenant/TenantPreviewGeneration.php b/core/lib/Models/Tenant/TenantPreviewGeneration.php new file mode 100644 index 0000000..5b92512 --- /dev/null +++ b/core/lib/Models/Tenant/TenantPreviewGeneration.php @@ -0,0 +1,72 @@ +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; + } +} diff --git a/core/lib/Models/Tenant/TenantPreviewVariant.php b/core/lib/Models/Tenant/TenantPreviewVariant.php new file mode 100644 index 0000000..cee5542 --- /dev/null +++ b/core/lib/Models/Tenant/TenantPreviewVariant.php @@ -0,0 +1,53 @@ +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, + ]; + } +} diff --git a/core/lib/Models/Tenant/TenantPreviewVariants.php b/core/lib/Models/Tenant/TenantPreviewVariants.php new file mode 100644 index 0000000..00d1c52 --- /dev/null +++ b/core/lib/Models/Tenant/TenantPreviewVariants.php @@ -0,0 +1,69 @@ + */ + 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 */ + 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 */ + private static function defaults(): array + { + return [ + 'popover' => new TenantPreviewVariant(640, 440, 'image/webp', 80), + 'thumbnail' => new TenantPreviewVariant(256, 256, 'image/webp', 75), + ]; + } +} diff --git a/tests/php/Unit/Preview/PreviewConfigurationTest.php b/tests/php/Unit/Preview/PreviewConfigurationTest.php new file mode 100644 index 0000000..3de13c7 --- /dev/null +++ b/tests/php/Unit/Preview/PreviewConfigurationTest.php @@ -0,0 +1,115 @@ +preview(); + + self::assertTrue($preview->enabled()); + self::assertSame('previews', $preview->store()); + self::assertSame(TenantPreviewGeneration::MODE_EAGER_WITH_LAZY_FALLBACK, $preview->generation()->mode()); + 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(440, $preview->variants()->variant('popover')?->height); + self::assertSame('image/webp', $preview->variants()->variant('popover')?->format); + self::assertSame(80, $preview->variants()->variant('popover')?->quality); + self::assertSame(256, $preview->variants()->variant('thumbnail')?->width); + } + + #[Test] + public function loadsAndSerializesTenantPreviewConfiguration(): void + { + $data = [ + 'enabled' => true, + 'store' => 'generated-content', + 'generation' => [ + 'mode' => TenantPreviewGeneration::MODE_LAZY, + 'maxSourceSize' => 1024, + 'timeoutSeconds' => 5, + 'retryDelaySeconds' => 60, + ], + 'variants' => [ + 'popover' => [ + 'width' => 800, + 'height' => 600, + 'format' => 'IMAGE/PNG', + 'quality' => 90, + ], + ], + ]; + + $configuration = (new TenantConfiguration())->jsonDeserialize(['preview' => $data]); + + self::assertSame('generated-content', $configuration->preview()->store()); + self::assertSame(TenantPreviewGeneration::MODE_LAZY, $configuration->preview()->generation()->mode()); + self::assertSame(['popover'], array_keys($configuration->preview()->variants()->all())); + self::assertSame('image/png', $configuration->preview()->variants()->variant('popover')?->format); + $data['variants']['popover']['format'] = 'image/png'; + self::assertSame($data, $configuration->jsonSerialize()['preview']); + } + + #[Test] + public function mergesKnownVariantDefaultsForPartialOverrides(): void + { + $configuration = (new TenantConfiguration())->jsonDeserialize([ + 'preview' => [ + 'variants' => [ + 'popover' => ['width' => 900], + ], + ], + ]); + + $variant = $configuration->preview()->variants()->variant('popover'); + self::assertSame(900, $variant?->width); + self::assertSame(440, $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}> */ + public static function invalidConfigurations(): iterable + { + yield 'invalid store' => [['store' => '../previews']]; + yield 'unknown generation mode' => [['generation' => ['mode' => 'eager']]]; + yield 'zero source limit' => [['generation' => ['maxSourceSize' => 0]]]; + yield 'invalid variant name' => [['variants' => ['../popover' => []]]]; + yield 'invalid dimensions' => [['variants' => ['popover' => ['width' => 0]]]]; + yield 'invalid output MIME' => [['variants' => ['popover' => ['format' => 'webp']]]]; + yield 'invalid quality' => [['variants' => ['popover' => ['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]); + } +}