From 6f8b36844e613a4e2534b61d8bf24abcf0dd4d9d Mon Sep 17 00:00:00 2001 From: Juan Claude Date: Wed, 11 Nov 2026 18:54:58 +0000 Subject: [PATCH 1/2] test(dashboard): pin DashboardService creation order, template rung and provisioning before the split Assisted-by: ClaudeCode:claude-opus-5-5 --- .../DashboardServiceCreationGoldenTest.php | 290 ++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 tests/Unit/Service/DashboardServiceCreationGoldenTest.php diff --git a/tests/Unit/Service/DashboardServiceCreationGoldenTest.php b/tests/Unit/Service/DashboardServiceCreationGoldenTest.php new file mode 100644 index 000000000..e5d672de2 --- /dev/null +++ b/tests/Unit/Service/DashboardServiceCreationGoldenTest.php @@ -0,0 +1,290 @@ + + * @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\AdminSettingMapper; +use OCA\LaunchPad\Db\Dashboard; +use OCA\LaunchPad\Db\DashboardMapper; +use OCA\LaunchPad\Db\WidgetPlacement; +use OCA\LaunchPad\Db\WidgetPlacementMapper; +use OCA\LaunchPad\Exception\QuotaExceededException; +use OCA\LaunchPad\Service\AdminTemplateService; +use OCA\LaunchPad\Service\DashboardBuilders; +use OCA\LaunchPad\Service\DashboardFactory; +use OCA\LaunchPad\Service\DashboardLimits; +use OCA\LaunchPad\Service\DashboardOutlets; +use OCA\LaunchPad\Service\DashboardPlatform; +use OCA\LaunchPad\Service\DashboardResolver; +use OCA\LaunchPad\Service\DashboardService; +use OCA\LaunchPad\Service\DashboardStores; +use OCA\LaunchPad\Service\DashboardStructure; +use OCA\LaunchPad\Service\DashboardTranslationService; +use OCA\LaunchPad\Service\DashboardTreeService; +use OCA\LaunchPad\Service\FooterService; +use OCA\LaunchPad\Service\PersonalLayerService; +use OCA\LaunchPad\Service\PublicShareContext; +use OCA\LaunchPad\Service\QuotaService; +use OCA\LaunchPad\Service\RoleFeaturePermissionService; +use OCA\LaunchPad\Service\TemplateService; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\L10N\IFactory; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use ReflectionMethod; +use RuntimeException; + +class DashboardServiceCreationGoldenTest extends TestCase { + /** @var list */ + private array $log = []; + + private DashboardMapper $dashboardMapper; + private WidgetPlacementMapper $placementMapper; + private AdminSettingMapper $settingMapper; + private TemplateService $templateService; + private DashboardFactory $factory; + private DashboardResolver $resolver; + private DashboardTreeService $treeService; + private DashboardTranslationService $translations; + private LoggerInterface $logger; + private PublicShareContext $shareContext; + private QuotaService $quota; + private ?RoleFeaturePermissionService $roles; + private IConfig $config; + + protected function setUp(): void { + $this->dashboardMapper = $this->createMock(DashboardMapper::class); + $this->placementMapper = $this->createMock(WidgetPlacementMapper::class); + $this->settingMapper = $this->createMock(AdminSettingMapper::class); + $this->templateService = $this->createMock(TemplateService::class); + $this->factory = $this->createMock(DashboardFactory::class); + $this->resolver = $this->createMock(DashboardResolver::class); + $this->treeService = $this->createMock(DashboardTreeService::class); + $this->translations = $this->createMock(DashboardTranslationService::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->shareContext = $this->createMock(PublicShareContext::class); + $this->quota = $this->createMock(QuotaService::class); + $this->roles = $this->createMock(RoleFeaturePermissionService::class); + $this->config = $this->createMock(IConfig::class); + + $this->shareContext->method('requireMutable')->willReturnCallback(fn () => $this->log[] = 'mutable'); + $this->quota->method('assertCanCreateDashboard')->willReturnCallback(fn (string $userId) => $this->log[] = 'quota:' . $userId); + $this->treeService->method('validateParent')->willReturnCallback(fn (?string $movingUuid, ?string $newParentUuid) => $this->log[] = 'parent:' . var_export($newParentUuid, true)); + $this->treeService->method('validateSlugUnique')->willReturnCallback(fn (?string $parentUuid, string $slug) => $this->log[] = 'slug:' . var_export($parentUuid, true) . ':' . $slug); + $this->factory->method('create')->willReturnCallback(function (string $userId, string $name, array $options): Dashboard { + $this->log[] = 'factory:' . $userId . ':' . $name . ':' . json_encode($options); + $dashboard = new Dashboard(); + $dashboard->setName($name); + $dashboard->setSlug($options['slug'] ?? 'derived'); + return $dashboard; + }); + $this->dashboardMapper->method('deactivateAllForUser')->willReturnCallback(fn (string $userId) => $this->log[] = 'deactivate:' . $userId); + $this->dashboardMapper->method('insert')->willReturnCallback(function (Dashboard $dashboard): Dashboard { + $this->log[] = 'insert:icon=' . var_export($dashboard->getIcon(), true); + $dashboard->setId(42); + return $dashboard; + }); + $this->translations->method('seedPrimaryFor')->willReturnCallback(function (Dashboard $dashboard) { + $this->log[] = 'translation:' . $dashboard->getId(); + if ($dashboard->getName() === 'Untranslatable') { + throw new RuntimeException('no locale'); + } + + return new \OCA\LaunchPad\Db\DashboardTranslation(); + }); + $this->placementMapper->method('insert')->willReturnCallback(function (WidgetPlacement $placement): WidgetPlacement { + $this->log[] = 'placement'; + return $placement; + }); + } + + private function service(bool $withLimits = true, bool $withTranslations = true): DashboardService { + return new DashboardService( + new DashboardStores($this->dashboardMapper, $this->placementMapper, $this->settingMapper), + new DashboardBuilders($this->templateService, $this->factory, $this->resolver, $this->createMock(AdminTemplateService::class)), + new DashboardStructure( + $this->treeService, + $this->createMock(PersonalLayerService::class), + $withTranslations ? $this->translations : null, + $this->createMock(FooterService::class), + ), + new DashboardPlatform($this->createMock(IGroupManager::class), $this->createMock(IDBConnection::class), $this->config, $this->createMock(IFactory::class)), + new DashboardOutlets($this->logger), + $withLimits ? new DashboardLimits($this->roles, $this->shareContext, $this->quota) : new DashboardLimits(), + ); + } + + public function testFullCreateOrder(): void { + $dashboard = $this->service()->createDashboard('alice', 'Board', [ + 'description' => 'D', + 'icon' => 'star', + 'parentUuid' => 'p-1', + 'slug' => 'board', + 'sortOrder' => 4, + 'seedDefaults' => true, + ]); + + $this->assertSame(42, $dashboard->getId()); + $this->assertSame([ + 'mutable', + 'quota:alice', + "parent:'p-1'", + 'factory:alice:Board:{"description":"D","parentUuid":"p-1","slug":"board","sortOrder":4}', + "slug:'p-1':board", + 'deactivate:alice', + "insert:icon='star'", + 'translation:42', + ], array_slice($this->log, 0, 8)); + $this->assertGreaterThan(8, count($this->log)); + $this->assertSame(['placement'], array_unique(array_slice($this->log, 8))); + } + + public function testDefaultsWithoutIconSlugTranslationOrSeed(): void { + $this->factory = $this->createMock(DashboardFactory::class); + $this->factory->method('create')->willReturnCallback(function (string $userId, string $name, array $options): Dashboard { + $this->log[] = 'factory:' . json_encode($options); + $dashboard = new Dashboard(); + $dashboard->setSlug(null); + return $dashboard; + }); + + $this->service(false, false)->createDashboard('bob', 'B', ['icon' => '']); + + $this->assertSame([ + 'parent:NULL', + 'factory:{"description":null,"parentUuid":null,"slug":null,"sortOrder":0}', + 'deactivate:bob', + 'insert:icon=NULL', + ], $this->log); + } + + public function testEmptySlugSkipsTheUniquenessCheck(): void { + $this->service()->createDashboard('bob', 'B', ['slug' => '']); + + $this->assertNotContains("slug:NULL:", $this->log); + $this->assertSame([], array_values(array_filter($this->log, static fn (string $l): bool => str_starts_with($l, 'slug:')))); + } + + public function testQuotaRefusalStopsBeforeAnyCheckOrWrite(): void { + $this->quota = $this->createMock(QuotaService::class); + $this->quota->method('assertCanCreateDashboard')->willThrowException(new QuotaExceededException(quota: QuotaExceededException::QUOTA_DASHBOARDS, limit: 1, current: 1)); + + try { + $this->service()->createDashboard('alice', 'B'); + $this->fail('expected the quota refusal'); + } catch (QuotaExceededException) { + $this->assertSame(['mutable'], $this->log); + } + } + + public function testSharedContextRefusalStopsFirst(): void { + $this->shareContext = $this->createMock(PublicShareContext::class); + $this->shareContext->method('requireMutable')->willThrowException(new RuntimeException('read only')); + + try { + $this->service()->createDashboard('alice', 'B'); + $this->fail('expected the refusal'); + } catch (RuntimeException $e) { + $this->assertSame(['read only', []], [$e->getMessage(), $this->log]); + } + } + + public function testFailedTranslationSeedIsLoggedAndCreationGoesOn(): void { + $this->logger->expects($this->once())->method('warning')->with( + 'launchpad: failed to seed primary translation: {message}', + ['message' => 'no locale'] + ); + + $dashboard = $this->service()->createDashboard('alice', 'Untranslatable'); + + $this->assertSame(42, $dashboard->getId()); + $this->assertSame('translation:42', end($this->log)); + } + + private function call(DashboardService $service, string $method, string $userId): mixed { + return (new ReflectionMethod(DashboardService::class, $method))->invoke($service, $userId); + } + + public function testProvisionWithATemplateHandsItToTheResolver(): void { + $template = new Dashboard(); + $this->settingMapper->method('getValue')->willReturn(false); + $this->templateService->method('getApplicableTemplate')->willReturn($template); + $this->resolver->expects($this->once())->method('handleTemplateResult')->with($template, false, 'alice')->willReturn(['from' => 'template']); + + $this->assertSame(['from' => 'template'], $this->call($this->service(), 'tryCreateFromTemplate', 'alice')); + } + + public function testNoTemplateAndNoPersonalDashboardsProvisionsNothing(): void { + $this->settingMapper->method('getValue')->willReturn(false); + $this->dashboardMapper->expects($this->never())->method('insert'); + + $this->assertNull($this->call($this->service(), 'tryCreateFromTemplate', 'alice')); + } + + /** + * Role seeding results and whether the default bundle is placed. + * + * @return array + */ + public static function roleSeeds(): array { + return ['role defaults' => [2, 'found'], 'nothing seeded' => [0, 'defaults'], 'no role service' => [null, 'defaults']]; + } + + /** + * @dataProvider roleSeeds + */ + public function testProvisionedDashboardPlacements(?int $seeded, string $placements): void { + $this->settingMapper->method('getValue')->willReturn(true); + if ($seeded === null) { + $this->roles = null; + } else { + $this->roles->method('seedLayoutFromRoleDefaults')->willReturnCallback(function (string $userId, Dashboard $dashboard) use ($seeded): int { + $this->log[] = 'roles:' . $userId . ':' . $dashboard->getId(); + return $seeded; + }); + } + + $found = [new WidgetPlacement()]; + $this->placementMapper->method('findByDashboardId')->willReturnCallback(function (int $dashboardId) use ($found): array { + $this->log[] = 'find:' . $dashboardId; + return $found; + }); + + $result = $this->call($this->service(), 'tryCreateFromTemplate', 'alice'); + + $this->assertSame(Dashboard::PERMISSION_FULL, $result['permissionLevel']); + $this->assertSame(42, $result['dashboard']->getId()); + $this->assertSame('My Dashboard', $result['dashboard']->getName()); + $this->assertSame("slug:NULL:my-dashboard", array_values(array_filter($this->log, static fn (string $l): bool => str_starts_with($l, 'slug:')))[0]); + if ($placements === 'found') { + $this->assertSame($found, $result['placements']); + $this->assertSame(['roles:alice:42', 'find:42'], array_slice($this->log, -2)); + } else { + $this->assertNotContains('find:42', $this->log); + $this->assertNotSame([], $result['placements']); + } + } +}//end class From 9862f567fbbeb6894c39f8bec25673cd68e4b6e8 Mon Sep 17 00:00:00 2001 From: Juan Claude Date: Wed, 11 Nov 2026 18:56:12 +0000 Subject: [PATCH 2/2] refactor(dashboard): split DashboardService creation, template rung and provisioning into named steps Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Service/DashboardService.php | 381 ++++++++++++++++++++----------- 1 file changed, 252 insertions(+), 129 deletions(-) diff --git a/lib/Service/DashboardService.php b/lib/Service/DashboardService.php index 23414fc88..e917d721c 100644 --- a/lib/Service/DashboardService.php +++ b/lib/Service/DashboardService.php @@ -752,49 +752,101 @@ private function resolveDefaultGroupDashboard(string $userId): ?array { * @spec openspec/specs/dashboards/spec.md */ public function createDashboard(string $userId, string $name, array $options=[]): Dashboard { - $options += self::CREATE_DEFAULTS; - $parentUuid = $options['parentUuid']; - // Task-7 of dashboard-public-share — public-share bearer cannot mutate. + $options += self::CREATE_DEFAULTS; + $this->assertMayCreate(userId: $userId, parentUuid: $options['parentUuid']); + + $dashboard = $this->builders->dashboardFactory->create( + userId: $userId, + name: $name, + options: self::factoryOptions(options: $options) + ); + $this->applyNewIcon(dashboard: $dashboard, icon: $options['icon']); + $this->assertNewSlugUnique(dashboard: $dashboard, parentUuid: $options['parentUuid']); + + $this->stores->dashboardMapper->deactivateAllForUser(userId: $userId); + + $persisted = $this->stores->dashboardMapper->insert(entity: $dashboard); + $this->completeNewDashboard(persisted: $persisted, options: $options); + + return $persisted; + }//end createDashboard() + + /** + * The checks every dashboard creation passes first, before any DB + * write or entity is built. + * + * Task-7 of dashboard-public-share — public-share bearer cannot mutate. + * Dashboard-quota-limits REQ-QUOTA-002: enforce the per-user + * dashboard quota at the single creation choke point so every + * user-initiated surface (REST create, CLI, template + * instantiation) is bound. Admin provisioning paths wrap their + * calls in QuotaService::runProvisioning() to bypass this + * (REQ-QUOTA-004). REQ-DASH-023, REQ-DASH-028: parent existence + + * cycle + depth checks BEFORE the entity is built so the request is + * rejected without a partial row in memory. + * + * @param string $userId The user ID. + * @param string|null $parentUuid The requested parent, or null for a root. + * + * @return void + */ + private function assertMayCreate(string $userId, ?string $parentUuid): void { $this->limits->publicShareContext?->requireMutable(); - // Dashboard-quota-limits REQ-QUOTA-002: enforce the per-user - // dashboard quota at the single creation choke point so every - // user-initiated surface (REST create, CLI, template - // instantiation) is bound. Admin provisioning paths wrap their - // calls in QuotaService::runProvisioning() to bypass this - // (REQ-QUOTA-004). Runs before any DB write. $this->limits->quotaService?->assertCanCreateDashboard(userId: $userId); - // REQ-DASH-023, REQ-DASH-028: parent existence + cycle + - // depth checks BEFORE the entity is built so the request is - // rejected without a partial row in memory. $this->structure->treeService->validateParent( movingUuid: null, newParentUuid: $parentUuid ); + }//end assertMayCreate() - $dashboard = $this->builders->dashboardFactory->create( - userId: $userId, - name: $name, - options: [ - 'description' => $options['description'], - 'parentUuid' => $parentUuid, - 'slug' => $options['slug'], - 'sortOrder' => $options['sortOrder'], - ] - ); + /** + * The options the dashboard factory takes from the create options. + * + * @param array $options The create options with defaults. + * + * @return array The factory options. + */ + private static function factoryOptions(array $options): array { + return [ + 'description' => $options['description'], + 'parentUuid' => $options['parentUuid'], + 'slug' => $options['slug'], + 'sortOrder' => $options['sortOrder'], + ]; + }//end factoryOptions() - // Icon is an opaque string (registry key or URL) — see the - // `dashboard-icons` capability. NULL/empty means "use the - // frontend default glyph". - if ($options['icon'] !== null && $options['icon'] !== '') { + /** + * Set a new dashboard's icon when one was given. + * + * Icon is an opaque string (registry key or URL) — see the + * `dashboard-icons` capability. NULL/empty means "use the + * frontend default glyph". + * + * @param Dashboard $dashboard The new dashboard. + * @param string|null $icon The requested icon. + * + * @return void + */ + private function applyNewIcon(Dashboard $dashboard, ?string $icon): void { + if ($icon !== null && $icon !== '') { // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $dashboard->setIcon($options['icon']); + $dashboard->setIcon($icon); } + }//end applyNewIcon() - // REQ-DASH-024: slug uniqueness within parent scope. The - // factory may have emitted NULL when the name yields no legal - // characters and the caller did not supply an explicit slug — - // skip the unique check in that case (NULL slugs are simply - // unaddressable by path). + /** + * REQ-DASH-024: slug uniqueness within parent scope. The + * factory may have emitted NULL when the name yields no legal + * characters and the caller did not supply an explicit slug — + * skip the unique check in that case (NULL slugs are simply + * unaddressable by path). + * + * @param Dashboard $dashboard The new dashboard. + * @param string|null $parentUuid The parent, or null for a root. + * + * @return void + */ + private function assertNewSlugUnique(Dashboard $dashboard, ?string $parentUuid): void { $resolvedSlug = $dashboard->getSlug(); if ($resolvedSlug !== null && $resolvedSlug !== '') { $this->structure->treeService->validateSlugUnique( @@ -802,42 +854,60 @@ public function createDashboard(string $userId, string $name, array $options=[]) slug: $resolvedSlug ); } + }//end assertNewSlugUnique() - $this->stores->dashboardMapper->deactivateAllForUser(userId: $userId); - - $persisted = $this->stores->dashboardMapper->insert(entity: $dashboard); - - // REQ-DASH-038, REQ-DASH-044: every newly created dashboard gets - // a primary translation row in the owner's Nextcloud locale (or - // the default fallback when no preference is set). The seed is - // best-effort — failure here does not abort dashboard creation, - // because the legacy materialiser keeps reads working until the - // seed retries via a subsequent translation API call. - if ($this->structure->translationService !== null) { - try { - $this->structure->translationService->seedPrimaryFor( - dashboard: $persisted - ); - } catch (Throwable $t) { - $this->outlets->logger->warning( - message: 'launchpad: failed to seed primary translation: {message}', - context: ['message' => $t->getMessage()] - ); - } - } + /** + * Seed a stored dashboard's primary translation and, when asked, its + * default widgets. + * + * User-initiated dashboards (the "+" affordance in the sidebar) + * arrive here with $seedDefaults=true and are bootstrapped with + * the standard tile + files bundle. The bootstrap path + * (tryCreateFromTemplate) keeps $seedDefaults=false because it + * runs its own role-default-driven seed afterwards and would + * otherwise double-seed. + * + * @param Dashboard $persisted The stored dashboard. + * @param array $options The create options with defaults. + * + * @return void + */ + private function completeNewDashboard(Dashboard $persisted, array $options): void { + $this->seedPrimaryTranslation(dashboard: $persisted); - // User-initiated dashboards (the "+" affordance in the sidebar) - // arrive here with $seedDefaults=true and are bootstrapped with - // the standard tile + files bundle. The bootstrap path - // (tryCreateFromTemplate) keeps $seedDefaults=false because it - // runs its own role-default-driven seed afterwards and would - // otherwise double-seed. if ($options['seedDefaults'] === true) { $this->seedDefaultWidgets(dashboardId: $persisted->getId()); } + }//end completeNewDashboard() - return $persisted; - }//end createDashboard() + /** + * REQ-DASH-038, REQ-DASH-044: every newly created dashboard gets + * a primary translation row in the owner's Nextcloud locale (or + * the default fallback when no preference is set). The seed is + * best-effort — failure here does not abort dashboard creation, + * because the legacy materialiser keeps reads working until the + * seed retries via a subsequent translation API call. + * + * @param Dashboard $dashboard The stored dashboard. + * + * @return void + */ + private function seedPrimaryTranslation(Dashboard $dashboard): void { + if ($this->structure->translationService === null) { + return; + } + + try { + $this->structure->translationService->seedPrimaryFor( + dashboard: $dashboard + ); + } catch (Throwable $t) { + $this->outlets->logger->warning( + message: 'launchpad: failed to seed primary translation: {message}', + context: ['message' => $t->getMessage()] + ); + } + }//end seedPrimaryTranslation() /** * Update a dashboard. @@ -1945,19 +2015,7 @@ private function resolveTemplateOrGroupShared(string $userId, array $visible, st */ private function resolveTemplateRung(string $userId): ?array { $template = $this->builders->templateService->getApplicableTemplate(userId: $userId); - if ($template === null) { - return null; - } - - // "First time" means: owns nothing yet. Whoever has a personal - // dashboard already made, or was given, their start. - if ($this->stores->dashboardMapper->findByUserId(userId: $userId) !== []) { - return null; - } - - if ($template->getTargetGroupsArray() === [] - && $this->hasPrimaryGroupDefault(userId: $userId) === true - ) { + if ($template === null || $this->templateStepsAside(userId: $userId, template: $template) === true) { return null; } @@ -1965,6 +2023,38 @@ private function resolveTemplateRung(string $userId): ?array { return ['dashboard' => $template, 'viewOnly' => true]; } + return ['dashboard' => $this->copyTemplateFor(userId: $userId, template: $template), 'viewOnly' => false]; + }//end resolveTemplateRung() + + /** + * Whether the applicable template is not this user's start: they own + * a dashboard already, or the template targets no group and their + * primary group has a default of its own. + * + * "First time" means: owns nothing yet. Whoever has a personal + * dashboard already made, or was given, their start. + * + * @param string $userId The user. + * @param Dashboard $template The applicable template. + * + * @return bool True when the template is not shown. + */ + private function templateStepsAside(string $userId, Dashboard $template): bool { + return $this->stores->dashboardMapper->findByUserId(userId: $userId) !== [] + || ($template->getTargetGroupsArray() === [] + && $this->hasPrimaryGroupDefault(userId: $userId) === true); + }//end templateStepsAside() + + /** + * Copy the template for the user and make the copy their active + * dashboard. + * + * @param string $userId The user. + * @param Dashboard $template The applicable template. + * + * @return Dashboard The copy. + */ + private function copyTemplateFor(string $userId, Dashboard $template): Dashboard { $copy = $this->builders->templateService->createDashboardFromTemplate( userId: $userId, template: $template @@ -1976,8 +2066,8 @@ private function resolveTemplateRung(string $userId): ?array { value: (string)$copy->getUuid() ); - return ['dashboard' => $copy, 'viewOnly' => false]; - }//end resolveTemplateRung() + return $copy; + }//end copyTemplateFor() /** * Whether the user's primary group has a default group dashboard. @@ -3030,10 +3120,7 @@ private function uniqueRootSlugFor(string $userId): string { */ private function tryCreateFromTemplate(string $userId): ?array { $allowUserDashboards = $this->getAllowUserDashboards(); - - $template = $this->builders->templateService->getApplicableTemplate( - userId: $userId - ); + $template = $this->builders->templateService->getApplicableTemplate(userId: $userId); if ($template !== null) { return $this->builders->dashResolver->handleTemplateResult( @@ -3044,59 +3131,95 @@ private function tryCreateFromTemplate(string $userId): ?array { } if ($allowUserDashboards === true) { - // 🔴 THE SLUG IS PER USER, BECAUSE THE ROOT SLUG NAMESPACE IS NOT. - // `validateSlugUnique()` looks for any root dashboard with this - // slug, whoever owns it, and every auto-provisioned dashboard is - // named 'My Dashboard'. So the SECOND user ever to reach this - // branch got `Slug must be unique among siblings` and the request - // answered HTTP 500 with no dashboard at all. Measured on a fresh - // instance: the first user provisioned, the next one 500'd. - $dashboard = $this->createDashboard( - userId: $userId, - name: 'My Dashboard', - options: ['slug' => $this->uniqueRootSlugFor(userId: $userId)] - ); + return $this->provisionPersonalDashboard(userId: $userId); + } - // REQ-RFP-002: when no admin template applies, prefer seeding - // from the user's RoleLayoutDefault rows. Only fall back to the - // hardcoded recommendations + activity pair when no role-defaults - // exist for any of the user's groups (or the user is in no group). - // The dependency is nullable to keep legacy PHPUnit doubles (built - // before role-based-content shipped) working — when null we treat - // it as "no defaults seeded" so the legacy hardcoded fallback runs. - $seeded = 0; - if ($this->limits->roleFeaturePerm !== null) { - $seeded = $this->limits->roleFeaturePerm->seedLayoutFromRoleDefaults( - userId: $userId, - dashboard: $dashboard - ); - } + return null; + }//end tryCreateFromTemplate() - // The comment above says the hardcoded pair is a FALLBACK, and it - // was not: it was created on every path, so a role-seeded layout - // came out carrying the role defaults AND tile/tile/tile/files. - $placements = []; - if ($seeded > 0) { - $placements = $this->stores->placementMapper->findByDashboardId( - dashboardId: $dashboard->getId() - ); - } + /** + * Provision a user's first personal dashboard, laid out from their + * role defaults or, without any, the default bundle. + * + * 🔴 THE SLUG IS PER USER, BECAUSE THE ROOT SLUG NAMESPACE IS NOT. + * `validateSlugUnique()` looks for any root dashboard with this + * slug, whoever owns it, and every auto-provisioned dashboard is + * named 'My Dashboard'. So the SECOND user ever to reach this + * branch got `Slug must be unique among siblings` and the request + * answered HTTP 500 with no dashboard at all. Measured on a fresh + * instance: the first user provisioned, the next one 500'd. + * + * @param string $userId The user ID. + * + * @return array{dashboard: Dashboard, placements: array, permissionLevel: string} + */ + private function provisionPersonalDashboard(string $userId): array { + $dashboard = $this->createDashboard( + userId: $userId, + name: 'My Dashboard', + options: ['slug' => $this->uniqueRootSlugFor(userId: $userId)] + ); - if ($seeded === 0) { - $placements = $this->createDefaultPlacements( - dashboardId: $dashboard->getId() - ); - } + $seeded = $this->seedFromRoleDefaults(userId: $userId, dashboard: $dashboard); - return [ - 'dashboard' => $dashboard, - 'placements' => $placements, - 'permissionLevel' => Dashboard::PERMISSION_FULL, - ]; - }//end if + return [ + 'dashboard' => $dashboard, + 'placements' => $this->provisionedPlacements(dashboard: $dashboard, seeded: $seeded), + 'permissionLevel' => Dashboard::PERMISSION_FULL, + ]; + }//end provisionPersonalDashboard() - return null; - }//end tryCreateFromTemplate() + /** + * REQ-RFP-002: when no admin template applies, prefer seeding + * from the user's RoleLayoutDefault rows. The dependency is nullable + * to keep legacy PHPUnit doubles (built before role-based-content + * shipped) working — when null we treat it as "no defaults seeded" so + * the legacy hardcoded fallback runs. + * + * @param string $userId The user ID. + * @param Dashboard $dashboard The provisioned dashboard. + * + * @return int The number of placements seeded from role defaults. + */ + private function seedFromRoleDefaults(string $userId, Dashboard $dashboard): int { + if ($this->limits->roleFeaturePerm === null) { + return 0; + } + + return $this->limits->roleFeaturePerm->seedLayoutFromRoleDefaults( + userId: $userId, + dashboard: $dashboard + ); + }//end seedFromRoleDefaults() + + /** + * The provisioned dashboard's placements: the role-seeded ones, or — + * only when nothing was seeded — the hardcoded default bundle. + * + * The hardcoded pair is a FALLBACK: it used to be created on every + * path, so a role-seeded layout came out carrying the role defaults + * AND tile/tile/tile/files. + * + * @param Dashboard $dashboard The provisioned dashboard. + * @param int $seeded The number of role-seeded placements. + * + * @return array The placements. + */ + private function provisionedPlacements(Dashboard $dashboard, int $seeded): array { + if ($seeded > 0) { + return $this->stores->placementMapper->findByDashboardId( + dashboardId: $dashboard->getId() + ); + } + + if ($seeded === 0) { + return $this->createDefaultPlacements( + dashboardId: $dashboard->getId() + ); + } + + return []; + }//end provisionedPlacements() /** * Create default widget placements for a new dashboard.