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'; })(), ); } }