diff --git a/core/lib/Preview/PreviewManager.php b/core/lib/Preview/PreviewManager.php index a3af648..a90d82e 100644 --- a/core/lib/Preview/PreviewManager.php +++ b/core/lib/Preview/PreviewManager.php @@ -29,6 +29,29 @@ final readonly class PreviewManager ) { } + /** @return array{enabled:bool,storage:bool,generation:bool} */ + public function availability(string $tenantId): array + { + $tenant = $this->tenants->fetchById($tenantId); + if ($tenant === null) { + return ['enabled' => false, 'storage' => false, 'generation' => false]; + } + + $configuration = $tenant->getConfiguration()->preview(); + $enabled = $configuration->enabled(); + $storage = $enabled + && $tenant->getConfiguration()->stores()->store($configuration->store()) !== null; + $generation = $storage && $this->previewProviders() !== []; + + if ($enabled && !$storage) { + $this->logger->debug('Preview storage is not configured', ['tenantId' => $tenantId]); + } elseif ($enabled && $storage && !$generation) { + $this->logger->debug('No preview generation provider is available', ['tenantId' => $tenantId]); + } + + return compact('enabled', 'storage', 'generation'); + } + public function fetch( string $tenantId, PreviewSource $source, @@ -45,6 +68,7 @@ final readonly class PreviewManager !$configuration->enabled() || $variantConfiguration === null || ($source->size !== null && $source->size > $configuration->maxSourceSize()) + || $tenant->getConfiguration()->stores()->store($configuration->store()) === null ) { return null; } @@ -69,6 +93,9 @@ final readonly class PreviewManager } $provider = $this->selectProvider($source->mimeType, $request); + if ($provider === null) { + return null; + } $result = $provider->generate($source, $request); $blob = $this->writeCache( $tenantId, @@ -218,11 +245,11 @@ final readonly class PreviewManager private function selectProvider( string $sourceMimeType, PreviewRequest $request, - ): PreviewProviderInterface { + ): ?PreviewProviderInterface { $sourceMimeType = MimeType::normalize($sourceMimeType); $candidates = []; - foreach ($this->providers->providers(ProviderInterface::TYPE_PREVIEW) as $identifier => $provider) { + foreach ($this->previewProviders() as $identifier => $provider) { if ( !$provider instanceof PreviewProviderInterface || !$provider->supports($sourceMimeType, $request) @@ -234,9 +261,7 @@ final readonly class PreviewManager } if ($candidates === []) { - throw new PreviewGenerationException( - "No preview provider supports {$sourceMimeType} to {$request->preferredMimeType}" - ); + return null; } usort($candidates, static function (array $left, array $right): int { @@ -246,4 +271,13 @@ final readonly class PreviewManager return $candidates[0]['provider']; } + + /** @return array */ + private function previewProviders(): array + { + return array_filter( + $this->providers->providers(ProviderInterface::TYPE_PREVIEW), + static fn(ProviderInterface $provider): bool => $provider instanceof PreviewProviderInterface, + ); + } } diff --git a/core/src/private.ts b/core/src/private.ts index 08eb1d7..97386d9 100644 --- a/core/src/private.ts +++ b/core/src/private.ts @@ -6,6 +6,7 @@ import { createPinia } from 'pinia' import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar' import { useModuleStore } from '@KTXC/stores/moduleStore' import { useTenantStore } from '@KTXC/stores/tenantStore' +import { usePreviewStore } from '@KTXC/stores/previewStore' import { useUserStore } from '@KTXC/stores/userStore' import { useL10nStore } from '@KTXC/stores/l10nStore' import { useThemeStore } from '@KTXC/stores/themeStore' @@ -41,6 +42,7 @@ globalWindow.Pinia = PiniaLib as unknown (async () => { const moduleStore = useModuleStore(); const tenantStore = useTenantStore(); + const previewStore = usePreviewStore(); const userStore = useUserStore(); const l10nStore = useL10nStore(); const themeStore = useThemeStore(); @@ -49,6 +51,7 @@ globalWindow.Pinia = PiniaLib as unknown try { const payload = await fetchWrapper.get('/init'); moduleStore.init(payload?.modules ?? {}); + previewStore.init(payload?.preview ?? null); tenantStore.init(payload?.tenant ?? null); userStore.init(payload?.user ?? {}); layoutStore.hydrateFromSettings(); diff --git a/core/src/shared/index.ts b/core/src/shared/index.ts index 0b7604e..f03bede 100644 --- a/core/src/shared/index.ts +++ b/core/src/shared/index.ts @@ -9,6 +9,8 @@ // Stores export { useModuleStore } from '../stores/moduleStore' export { useTenantStore } from '../stores/tenantStore' +export { usePreviewStore } from '../stores/previewStore' +export type { PreviewAvailability } from '../stores/previewStore' export { useUserStore } from '../stores/userStore' export { useIntegrationStore } from '../stores/integrationStore' export { useLayoutStore } from '../stores/layoutStore' diff --git a/core/src/stores/previewStore.ts b/core/src/stores/previewStore.ts new file mode 100644 index 0000000..6286aa0 --- /dev/null +++ b/core/src/stores/previewStore.ts @@ -0,0 +1,36 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export interface PreviewAvailability { + enabled: boolean + storage: boolean + generation: boolean +} + +const unavailable = (): PreviewAvailability => ({ + enabled: false, + storage: false, + generation: false, +}) + +export const usePreviewStore = defineStore('previewStore', () => { + const availability = ref(unavailable()) + + function init(data?: Partial | null): void { + availability.value = { + enabled: data?.enabled ?? false, + storage: data?.storage ?? false, + generation: data?.generation ?? false, + } + } + + function reset(): void { + availability.value = unavailable() + } + + return { + availability, + init, + reset, + } +}) diff --git a/tests/php/Unit/Preview/PreviewManagerTest.php b/tests/php/Unit/Preview/PreviewManagerTest.php index 5cd8169..2203d4b 100644 --- a/tests/php/Unit/Preview/PreviewManagerTest.php +++ b/tests/php/Unit/Preview/PreviewManagerTest.php @@ -154,12 +154,7 @@ final class PreviewManagerTest extends TestCase $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 - ), - ); + $logger->expects(self::never())->method('warning'); self::assertNull($this->manager( $provider, @@ -169,6 +164,50 @@ final class PreviewManagerTest extends TestCase )->fetch('tenant-a', $this->source())); } + #[Test] + public function missingPreviewStorageIsUnavailableWithoutLoggingOrProviderLookup(): void + { + $provider = $this->provider(); + $provider->expects(self::never())->method('generate'); + $providerManager = $this->createMock(ProviderManager::class); + $providerManager->expects(self::never())->method('providers'); + $service = $this->createMock(SystemStoreServiceInterface::class); + $service->expects(self::never())->method('stat'); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::never())->method('warning'); + + self::assertNull($this->manager( + $provider, + $service, + new TenantConfiguration(), + $providerManager, + $logger, + )->fetch('tenant-a', $this->source())); + } + + #[Test] + public function reportsStorageAndGenerationAvailabilitySeparately(): void + { + $provider = $this->provider(); + $providerManager = $this->createMock(ProviderManager::class); + $providerManager->expects(self::once())->method('providers')->willReturn([]); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('debug')->with( + 'No preview generation provider is available', + ['tenantId' => 'tenant-a'], + ); + + self::assertSame( + ['enabled' => true, 'storage' => true, 'generation' => false], + $this->manager( + $provider, + $this->createStub(SystemStoreServiceInterface::class), + providerManager: $providerManager, + logger: $logger, + )->availability('tenant-a'), + ); + } + #[Test] public function cacheKeysAreDeterministicOpaqueAndChangeWithSourceIdentity(): void {