diff --git a/lib/Service/DemoShowcasesService.php b/lib/Service/DemoShowcasesService.php index b4d1cd7fb..053bf69b3 100644 --- a/lib/Service/DemoShowcasesService.php +++ b/lib/Service/DemoShowcasesService.php @@ -219,29 +219,67 @@ public function getAvailableShowcases(): array { * @spec openspec/specs/demo-data-showcases/spec.md */ public function describeShowcase(string $showcaseId): ?array { - $zipPath = $this->getZipPath(showcaseId: $showcaseId); - if (file_exists(filename: $zipPath) === false) { + $manifest = $this->readShowcaseManifest(showcaseId: $showcaseId); + if ($manifest === null) { return null; } - $manifest = $this->readManifest(zipPath: $zipPath); - if ($manifest === null) { + return $this->descriptorFor(showcaseId: $showcaseId, manifest: $manifest); + }//end describeShowcase() + + /** + * The manifest of a showcase's archive, null when the archive is + * absent or its manifest unreadable. + * + * @param string $showcaseId The showcase ID. + * + * @return array|null The decoded manifest. + */ + private function readShowcaseManifest(string $showcaseId): ?array { + $zipPath = $this->getZipPath(showcaseId: $showcaseId); + if (file_exists(filename: $zipPath) === false) { return null; } + return $this->readManifest(zipPath: $zipPath); + }//end readShowcaseManifest() + + /** + * Build the gallery descriptor of a readable showcase. + * + * @param string $showcaseId The showcase ID. + * @param array $manifest The decoded manifest. + * + * @return array The descriptor. + */ + private function descriptorFor(string $showcaseId, array $manifest): array { $installedUuid = $this->getInstalledUuid(showcaseId: $showcaseId); - $installedDashboardId = null; - if ($installedUuid !== '') { - $installedDashboardId = $installedUuid; - } + return [ + 'id' => $showcaseId, + 'name' => (string)($manifest['showcaseName'] ?? $showcaseId), + 'description' => (string)($manifest['showcaseDescription'] ?? ''), + 'language' => (string)($manifest['showcaseLanguage'] ?? 'nl'), + 'thumbnailUrl' => $this->thumbnailUrl(showcaseId: $showcaseId), + 'isInstalled' => $installedUuid !== '', + 'installedDashboardUuid' => $this->installedDashboardUuid(installedUuid: $installedUuid), + ]; + }//end descriptorFor() + + /** + * The showcase's preview image URL, null (logged) when it is absent. + * + * @param string $showcaseId The showcase ID. + * + * @return string|null The URL, or null. + */ + private function thumbnailUrl(string $showcaseId): ?string { // The imagePath() call THROWS when the image is absent, and this runs once per // showcase inside the listing, so one showcase without a preview used // to 500 the whole gallery. A missing thumbnail is a cosmetic gap in // one card, not a reason to hide the other five. - $thumbnailUrl = null; try { - $thumbnailUrl = $this->platform->urlGenerator->imagePath( + return $this->platform->urlGenerator->imagePath( Application::APP_ID, 'showcases/' . $showcaseId . '.png' ); @@ -250,18 +288,24 @@ public function describeShowcase(string $showcaseId): ?array { message: 'Showcase ' . $showcaseId . ' has no preview image', context: ['exception' => $e] ); + return null; } + }//end thumbnailUrl() - return [ - 'id' => $showcaseId, - 'name' => (string)($manifest['showcaseName'] ?? $showcaseId), - 'description' => (string)($manifest['showcaseDescription'] ?? ''), - 'language' => (string)($manifest['showcaseLanguage'] ?? 'nl'), - 'thumbnailUrl' => $thumbnailUrl, - 'isInstalled' => $installedUuid !== '', - 'installedDashboardUuid' => $installedDashboardId, - ]; - }//end describeShowcase() + /** + * The installed dashboard UUID, null when the showcase is not installed. + * + * @param string $installedUuid The recorded UUID, or `''`. + * + * @return string|null The UUID, or null. + */ + private function installedDashboardUuid(string $installedUuid): ?string { + if ($installedUuid === '') { + return null; + } + + return $installedUuid; + }//end installedDashboardUuid() /** * Install a showcase as a `group_shared` dashboard. @@ -483,21 +527,11 @@ public function getInstalledUuid(string $showcaseId): string { * skip (REQ-DEMO-005). * * A widget is kept when LaunchPad can render it. That is true in three - * cases, checked in this order: - * - * 1. it is a tile (`tileType` set), rendered by LaunchPad's tile renderer; - * 2. its `widgetId` is one of LaunchPad's OWN widget types - * (`lib/widget-types.json`: `object-list`, `nc-widget`, `calendar`, - * `text` and the rest), rendered by the workspace's widget registry; - * 3. its `widgetId` is a Nextcloud dashboard widget registered on this - * instance (`IManager::getWidgets()`), rendered through the bridge. - * - * Anything else is skipped, which is what the skip exists for: a bare - * Nextcloud widget id whose app is not installed here. - * - * Case 2 is new. Before it, a LaunchPad type counted as "unknown" - * because it is not in Nextcloud's registry, so the `case-handler` - * showcase, built entirely from LaunchPad types, installed as an empty + * cases: it is a tile (`tileType` set), it is one of LaunchPad's own + * widget types (`lib/widget-types.json`), or it is a Nextcloud dashboard + * widget registered on this instance. Before the second case existed + * every LaunchPad widget was skipped, so a showcase built entirely from + * LaunchPad types, installed as an empty * dashboard and still reported success. * * An `nc-widget` is kept whatever widget it proxies. The check is on the @@ -514,44 +548,55 @@ public function getInstalledUuid(string $showcaseId): string { * @spec openspec/specs/demo-data-showcases/spec.md */ public function partitionWidgets(array $widgets): array { - $registered = []; - foreach ($this->platform->dashboardManager->getWidgets() as $widget) { - $registered[$widget->getId()] = true; - } - - $ownTypes = $this->getLaunchpadWidgetTypes(); + $registered = $this->registeredWidgetIds(); + $known = $this->getLaunchpadWidgetTypes() + $registered; $valid = []; $skipped = []; - foreach ($widgets as $widget) { - if (is_array($widget) === false) { - continue; + foreach (array_filter(array: $widgets, callback: 'is_array') as $widget) { + if ($this->isPlaceable(widget: $widget, known: $known) === true) { + $valid[] = $widget; + } elseif ((string)($widget['widgetId'] ?? '') !== '') { + $skipped[] = (string)$widget['widgetId']; } + } - $widgetId = (string)($widget['widgetId'] ?? ''); - $tileType = (string)($widget['tileType'] ?? ''); + return [$valid, $skipped]; + }//end partitionWidgets() - // Tile placements bypass the widget registry — they render - // through the LaunchPad tile renderer (REQ-DEMO-005). - if ($tileType !== '') { - $valid[] = $widget; - continue; - } + /** + * The ids of the Nextcloud dashboard widgets registered on this + * instance, keyed for `isset()` lookups. + * + * @return array The registered ids. + */ + private function registeredWidgetIds(): array { + $registered = []; + foreach ($this->platform->dashboardManager->getWidgets() as $widget) { + $registered[$widget->getId()] = true; + } - if (isset($ownTypes[$widgetId]) === true - || isset($registered[$widgetId]) === true - ) { - $valid[] = $widget; - continue; - } + return $registered; + }//end registeredWidgetIds() - if ($widgetId !== '') { - $skipped[] = $widgetId; - } - }//end foreach + /** + * Whether LaunchPad can render a showcase widget: a tile, or a widget + * whose id is a known LaunchPad or registered Nextcloud type. + * + * @param array $widget The widget payload. + * @param array $known The renderable widget ids. + * + * @return bool True when the widget is placed. + */ + private function isPlaceable(array $widget, array $known): bool { + // Tile placements bypass the widget registry — they render + // through the LaunchPad tile renderer (REQ-DEMO-005). + if ((string)($widget['tileType'] ?? '') !== '') { + return true; + } - return [$valid, $skipped]; - }//end partitionWidgets() + return isset($known[(string)($widget['widgetId'] ?? '')]) === true; + }//end isPlaceable() /** * Load LaunchPad's own widget types from `lib/widget-types.json`. @@ -573,30 +618,52 @@ private function getLaunchpadWidgetTypes(): array { return $this->launchpadWidgetTypes; } - $decoded = null; - if (is_readable(filename: self::WIDGET_TYPES_PATH) === true) { - $raw = file_get_contents(filename: self::WIDGET_TYPES_PATH); - if (is_string($raw) === true) { - $decoded = json_decode(json: $raw, associative: true); - } - } - + $decoded = $this->readWidgetTypesFile(); if (is_array($decoded) === false || is_array($decoded['types'] ?? null) === false) { throw new RuntimeException( message: 'LaunchPad widget type list missing or malformed: ' . self::WIDGET_TYPES_PATH ); } - $types = []; - foreach ($decoded['types'] as $type) { + $this->launchpadWidgetTypes = $this->typeSet(types: $decoded['types']); + return $this->launchpadWidgetTypes; + }//end getLaunchpadWidgetTypes() + + /** + * Decode `lib/widget-types.json`, null when it cannot be read. + * + * @return mixed The decoded JSON, or null. + */ + private function readWidgetTypesFile(): mixed { + if (is_readable(filename: self::WIDGET_TYPES_PATH) === false) { + return null; + } + + $raw = file_get_contents(filename: self::WIDGET_TYPES_PATH); + if (is_string($raw) === false) { + return null; + } + + return json_decode(json: $raw, associative: true); + }//end readWidgetTypesFile() + + /** + * The non-empty string entries of the type list, keyed for `isset()`. + * + * @param array $types The `types` list. + * + * @return array The types. + */ + private function typeSet(array $types): array { + $set = []; + foreach ($types as $type) { if (is_string($type) === true && $type !== '') { - $types[$type] = true; + $set[$type] = true; } } - $this->launchpadWidgetTypes = $types; - return $types; - }//end getLaunchpadWidgetTypes() + return $set; + }//end typeSet() /** * Resolve the on-disk ZIP path for a showcase ID. @@ -669,35 +736,79 @@ private function loadDashboardPayload(string $zipPath): array { } try { - for ($i = 0; $i < $zip->numFiles; $i++) { - $name = (string)$zip->getNameIndex(index: $i); - if (str_starts_with(haystack: $name, needle: 'dashboards/') === false) { - continue; - } - - if (str_ends_with(haystack: $name, needle: '.json') === false) { - continue; - } - - $raw = $zip->getFromIndex(index: $i); - if ($raw === false) { - continue; - } - - $decoded = json_decode(json: $raw, associative: true); - if (is_array($decoded) === true) { - return $decoded; - } - } + $payload = $this->firstDashboardEntry(zip: $zip); } finally { $zip->close(); - }//end try + } + + if ($payload !== null) { + return $payload; + } throw new RuntimeException( message: 'Showcase archive contained no dashboard JSON entry.' ); }//end loadDashboardPayload() + /** + * The first `dashboards/*.json` entry of an open archive that decodes + * to a JSON object or list. + * + * @param ZipArchive $zip The open archive. + * + * @return array|null The payload, or null when none decodes. + */ + private function firstDashboardEntry(ZipArchive $zip): ?array { + for ($i = 0; $i < $zip->numFiles; $i++) { + if ($this->isDashboardEntryName(name: (string)$zip->getNameIndex(index: $i)) === false) { + continue; + } + + $decoded = $this->decodeZipEntry(zip: $zip, index: $i); + if ($decoded !== null) { + return $decoded; + } + } + + return null; + }//end firstDashboardEntry() + + /** + * Whether an archive entry name is a dashboard payload + * (`dashboards/*.json`). + * + * @param string $name The entry name. + * + * @return bool True for a dashboard payload entry. + */ + private function isDashboardEntryName(string $name): bool { + return str_starts_with(haystack: $name, needle: 'dashboards/') === true + && str_ends_with(haystack: $name, needle: '.json') === true; + }//end isDashboardEntryName() + + /** + * Decode one archive entry, null when it is unreadable or not a JSON + * object or list. + * + * @param ZipArchive $zip The open archive. + * @param integer $index The entry index. + * + * @return array|null The decoded entry, or null. + */ + private function decodeZipEntry(ZipArchive $zip, int $index): ?array { + $raw = $zip->getFromIndex(index: $index); + if ($raw === false) { + return null; + } + + $decoded = json_decode(json: $raw, associative: true); + if (is_array($decoded) === false) { + return null; + } + + return $decoded; + }//end decodeZipEntry() + /** * Hydrate a Dashboard entity for a freshly-installed showcase. * diff --git a/tests/Unit/Service/DemoShowcasesBranchesTest.php b/tests/Unit/Service/DemoShowcasesBranchesTest.php new file mode 100644 index 000000000..b245f149c --- /dev/null +++ b/tests/Unit/Service/DemoShowcasesBranchesTest.php @@ -0,0 +1,231 @@ + + * @copyright 2026 Conduction b.v. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 LaunchPad Contributors + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace Unit\Service; + +use OCA\LaunchPad\Db\DashboardMapper; +use OCA\LaunchPad\Db\WidgetPlacementMapper; +use OCA\LaunchPad\Service\DemoShowcasesService; +use OCA\LaunchPad\Service\ShowcasePlatform; +use OCA\LaunchPad\Service\ShowcaseStores; +use OCP\Dashboard\IManager; +use OCP\Dashboard\IWidget; +use OCP\IAppConfig; +use OCP\IDBConnection; +use OCP\IURLGenerator; +use OCP\Lock\ILockingProvider; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use ReflectionMethod; +use RuntimeException; +use ZipArchive; + +class DemoShowcasesBranchesTest extends TestCase { + private IAppConfig $appConfig; + private IManager $dashboardManager; + private DemoShowcasesService $service; + private string $fixtureDir; + + protected function setUp(): void { + $this->appConfig = $this->createMock(originalClassName: IAppConfig::class); + $this->dashboardManager = $this->createMock(originalClassName: IManager::class); + $urlGenerator = $this->createMock(originalClassName: IURLGenerator::class); + $urlGenerator->method('imagePath')->willReturnCallback( + static fn (string $app, string $file): string => 'img:' . $file + ); + + $this->service = new DemoShowcasesService( + new ShowcaseStores( + $this->createMock(originalClassName: DashboardMapper::class), + $this->createMock(originalClassName: WidgetPlacementMapper::class), + $this->createMock(originalClassName: IDBConnection::class), + $this->createMock(originalClassName: ILockingProvider::class), + ), + new ShowcasePlatform($this->appConfig, $this->dashboardManager, $urlGenerator), + new NullLogger(), + ); + + $this->fixtureDir = sys_get_temp_dir() . '/launchpad-showcase-branches-' . uniqid(); + mkdir($this->fixtureDir, 0o755, true); + $this->service->setDataDirForTesting(path: $this->fixtureDir); + } + + protected function tearDown(): void { + foreach (glob($this->fixtureDir . '/*/*') ?: [] as $file) { + unlink($file); + } + + foreach (glob($this->fixtureDir . '/*') ?: [] as $dir) { + rmdir($dir); + } + + rmdir($this->fixtureDir); + } + + /** + * Write a showcase archive with the given entries. + * + * @param string $showcaseId The showcase id. + * @param array $entries Entry name => contents. + * + * @return string The archive path. + */ + private function archive(string $showcaseId, array $entries): string { + $dir = $this->fixtureDir . '/' . $showcaseId; + mkdir($dir, 0o755, true); + $path = $dir . '/' . $showcaseId . '.zip'; + $zip = new ZipArchive(); + $zip->open($path, ZipArchive::CREATE); + foreach ($entries as $name => $contents) { + $zip->addFromString($name, $contents); + } + + $zip->close(); + + return $path; + } + + /** + * Call the private payload loader. + * + * @param string $path The archive path. + * + * @return array + */ + private function loadPayload(string $path): array { + $method = new ReflectionMethod(DemoShowcasesService::class, 'loadDashboardPayload'); + + return $method->invoke($this->service, $path); + } + + public function testCorruptArchiveHasNoDescriptor(): void { + mkdir($this->fixtureDir . '/x', 0o755, true); + file_put_contents($this->fixtureDir . '/x/x.zip', 'not a zip'); + + $this->assertNull($this->service->describeShowcase(showcaseId: 'x')); + } + + public function testArchiveWithoutManifestHasNoDescriptor(): void { + $this->archive('x', ['other.json' => '{}']); + + $this->assertNull($this->service->describeShowcase(showcaseId: 'x')); + } + + public function testArchiveWithScalarManifestHasNoDescriptor(): void { + $this->archive('x', ['manifest.json' => '"text"']); + + $this->assertNull($this->service->describeShowcase(showcaseId: 'x')); + } + + public function testDescriptorDefaultsAndInstallState(): void { + $this->archive('x', ['manifest.json' => '{}']); + $this->appConfig->method('getValueString')->willReturn('uuid-1'); + + $this->assertSame( + [ + 'id' => 'x', + 'name' => 'x', + 'description' => '', + 'language' => 'nl', + 'thumbnailUrl' => 'img:showcases/x.png', + 'isInstalled' => true, + 'installedDashboardUuid' => 'uuid-1', + ], + $this->service->describeShowcase(showcaseId: 'x') + ); + } + + public function testDescriptorFromManifestNotInstalled(): void { + $this->archive('x', ['manifest.json' => '{"showcaseName":"X","showcaseDescription":"D","showcaseLanguage":"en"}']); + $this->appConfig->method('getValueString')->willReturn(''); + + $result = $this->service->describeShowcase(showcaseId: 'x'); + + $this->assertSame(['X', 'D', 'en', false, null], [ + $result['name'], + $result['description'], + $result['language'], + $result['isInstalled'], + $result['installedDashboardUuid'], + ]); + } + + public function testPartitionKeepsTilesOwnAndRegisteredTypesInOrder(): void { + $registered = $this->createMock(originalClassName: IWidget::class); + $registered->method('getId')->willReturn('nc-registered'); + $this->dashboardManager->method('getWidgets')->willReturn([$registered]); + + [$valid, $skipped] = $this->service->partitionWidgets(widgets: [ + 'not-an-array', + ['widgetId' => 'nope-1'], + ['widgetId' => 'nc-registered', 'x' => 1], + ['widgetId' => 'nope-2', 'tileType' => 'shortcut'], + ['widgetId' => 'object-list'], + ['tileType' => ''], + ['widgetId' => ''], + ['widgetId' => 'nope-3', 'tileType' => ''], + ]); + + $this->assertSame( + [ + ['widgetId' => 'nc-registered', 'x' => 1], + ['widgetId' => 'nope-2', 'tileType' => 'shortcut'], + ['widgetId' => 'object-list'], + ], + $valid + ); + $this->assertSame(['nope-1', 'nope-3'], $skipped); + } + + public function testPayloadIsTheFirstDecodableDashboardEntry(): void { + $path = $this->archive('x', [ + 'manifest.json' => '{"a":1}', + 'dashboards/readme.txt' => '{"b":1}', + 'other/d.json' => '{"c":1}', + 'dashboards/broken.json' => 'nope', + 'dashboards/scalar.json' => '5', + 'dashboards/first.json' => '{"name":"first"}', + 'dashboards/second.json' => '{"name":"second"}', + ]); + + $this->assertSame(['name' => 'first'], $this->loadPayload($path)); + } + + public function testArchiveWithoutDashboardEntryIsRefused(): void { + $path = $this->archive('x', ['manifest.json' => '{}', 'dashboards/x.txt' => '{}']); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Showcase archive contained no dashboard JSON entry.'); + + $this->loadPayload($path); + } + + public function testUnopenableArchiveIsRefused(): void { + $path = $this->fixtureDir . '/missing.zip'; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not open showcase archive: ' . $path); + + $this->loadPayload($path); + } +}//end class