diff --git a/shared/lib/Resource/Provider/ProviderInterface.php b/shared/lib/Resource/Provider/ProviderInterface.php index 85e9d57..44958f9 100644 --- a/shared/lib/Resource/Provider/ProviderInterface.php +++ b/shared/lib/Resource/Provider/ProviderInterface.php @@ -15,6 +15,7 @@ interface ProviderInterface public const TYPE_PEOPLE = 'people'; public const TYPE_CHRONO = 'chrono'; public const TYPE_MAIL = 'mail'; + public const TYPE_SYSTEM_STORE = 'system-store'; /** * Provider type (e.g., 'authentication', 'document', 'people', 'chrono', 'mail') diff --git a/shared/lib/SystemStore/BlobInfo.php b/shared/lib/SystemStore/BlobInfo.php new file mode 100644 index 0000000..49ac9c9 --- /dev/null +++ b/shared/lib/SystemStore/BlobInfo.php @@ -0,0 +1,40 @@ +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'); + } + } + +} diff --git a/shared/lib/SystemStore/Provider/ProviderBaseInterface.php b/shared/lib/SystemStore/Provider/ProviderBaseInterface.php new file mode 100644 index 0000000..d179485 --- /dev/null +++ b/shared/lib/SystemStore/Provider/ProviderBaseInterface.php @@ -0,0 +1,15 @@ + + */ + public function list(string $prefix = ''): iterable; +} diff --git a/shared/lib/SystemStore/WriteCondition.php b/shared/lib/SystemStore/WriteCondition.php new file mode 100644 index 0000000..6237a95 --- /dev/null +++ b/shared/lib/SystemStore/WriteCondition.php @@ -0,0 +1,49 @@ +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); + } +} diff --git a/tests/php/Unit/SystemStore/SystemStoreContractsTest.php b/tests/php/Unit/SystemStore/SystemStoreContractsTest.php new file mode 100644 index 0000000..16655a1 --- /dev/null +++ b/tests/php/Unit/SystemStore/SystemStoreContractsTest.php @@ -0,0 +1,83 @@ +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 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 serializesMetadata(): 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([ + 'key' => 'previews/example.webp', + 'mime' => 'image/webp', + 'size' => 123, + 'etag' => 'revision-1', + 'modifiedAt' => '2026-08-28T12:00:00+00:00', + 'attributes' => ['variant' => 'popover'], + ], $metadata->jsonSerialize()); + } +}