From 3dda3fb03bd61a0b2f25a5dd28697b580c2d8bae Mon Sep 17 00:00:00 2001 From: Juan Claude Date: Wed, 11 Nov 2026 18:32:56 +0000 Subject: [PATCH 1/2] test(dashboard-api): pin DashboardApiController action outcomes before the split Assisted-by: ClaudeCode:claude-opus-5-5 --- ...ashboardApiControllerActionsGoldenTest.php | 366 ++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 tests/Unit/Controller/DashboardApiControllerActionsGoldenTest.php diff --git a/tests/Unit/Controller/DashboardApiControllerActionsGoldenTest.php b/tests/Unit/Controller/DashboardApiControllerActionsGoldenTest.php new file mode 100644 index 000000000..ac771dfad --- /dev/null +++ b/tests/Unit/Controller/DashboardApiControllerActionsGoldenTest.php @@ -0,0 +1,366 @@ + + * @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\Controller; + +use Exception; +use InvalidArgumentException; +use OCA\LaunchPad\Controller\DashboardApiController; +use OCA\LaunchPad\Controller\DashboardApiHistory; +use OCA\LaunchPad\Controller\DashboardApiServices; +use OCA\LaunchPad\Controller\RequestActor; +use OCA\LaunchPad\Db\Dashboard; +use OCA\LaunchPad\Exception\PersonalDashboardsDisabledException; +use OCA\LaunchPad\Exception\QuotaExceededException; +use OCA\LaunchPad\Service\ActionAuthService; +use OCA\LaunchPad\Service\AnalyticsService; +use OCA\LaunchPad\Service\DashboardService; +use OCA\LaunchPad\Service\DashboardTreeService; +use OCA\LaunchPad\Service\DashboardVersionService; +use OCA\LaunchPad\Service\PermissionService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use ReflectionMethod; +use RuntimeException; + +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Mirrors the controller constructor. + */ +class DashboardApiControllerActionsGoldenTest extends TestCase { + private DashboardService $dashboardService; + private PermissionService $permissionService; + private DashboardTreeService $treeService; + private LoggerInterface $logger; + private bool $sessionUser = true; + + protected function setUp(): void { + $this->dashboardService = $this->createMock(DashboardService::class); + $this->permissionService = $this->createMock(PermissionService::class); + $this->treeService = $this->createMock(DashboardTreeService::class); + $this->logger = $this->createMock(LoggerInterface::class); + } + + private function controller(?string $userId = 'alice'): DashboardApiController { + $session = $this->createMock(IUserSession::class); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $session->method('getUser')->willReturn($this->sessionUser ? $user : null); + + return new DashboardApiController( + $this->createMock(IRequest::class), + new DashboardApiServices($this->dashboardService), + $this->permissionService, + new DashboardApiHistory( + $this->treeService, + $this->createMock(DashboardVersionService::class), + $this->createMock(AnalyticsService::class), + $this->logger, + ), + new RequestActor($session, $this->createMock(ActionAuthService::class)), + $userId, + ); + } + + private function outcome(JSONResponse $response): array { + return [$response->getStatus(), $response->getData()]; + } + + private function dashboard(string $uuid = 'd-1', ?string $parent = null): Dashboard { + $dashboard = new Dashboard(); + $dashboard->setId(5); + $dashboard->setUuid($uuid); + $dashboard->setName('N'); + $dashboard->setParentUuid($parent); + + return $dashboard; + } + + private function allowCreate(): void { + $this->permissionService->method('canCreateDashboard')->willReturn(true); + $this->permissionService->method('canHaveMultipleDashboards')->willReturn(true); + $this->dashboardService->method('getUserDashboards')->willReturn([]); + } + + public function testCreateWithoutSessionIsUnauthenticated(): void { + $this->sessionUser = false; + $this->dashboardService->expects($this->never())->method('createDashboard'); + + $this->assertSame([401, ['error' => 'Not authenticated']], $this->outcome($this->controller()->create(name: 'x'))); + } + + public function testCreateWithPersonalDashboardsOffIsRefused(): void { + $this->dashboardService->method('assertPersonalDashboardsAllowed')->willThrowException(new PersonalDashboardsDisabledException()); + $this->permissionService->expects($this->never())->method('canCreateDashboard'); + + [$status, $data] = $this->outcome($this->controller()->create(name: 'x')); + + $this->assertSame([403, 'error', 'personal_dashboards_disabled'], [$status, $data['status'], $data['error']]); + } + + public function testCreateNotAllowedIsForbidden(): void { + $this->permissionService->method('canCreateDashboard')->willReturn(false); + $this->dashboardService->expects($this->never())->method('getUserDashboards'); + + $this->assertSame([403, ['error' => 'Dashboard creation not allowed']], $this->outcome($this->controller()->create(name: 'x'))); + } + + public function testSecondDashboardNotAllowedIsForbidden(): void { + $this->permissionService->method('canCreateDashboard')->willReturn(true); + $this->permissionService->method('canHaveMultipleDashboards')->willReturn(false); + $this->dashboardService->method('getUserDashboards')->willReturn([$this->dashboard()]); + $this->dashboardService->expects($this->never())->method('createDashboard'); + + $this->assertSame([403, ['error' => 'Multiple dashboards not allowed']], $this->outcome($this->controller()->create(name: 'x'))); + } + + public function testCreatePassesFlatParametersAndAnswersWithPlacements(): void { + $this->allowCreate(); + $this->dashboardService->expects($this->once())->method('createDashboard')->with( + 'alice', + 'Board', + ['seedDefaults' => true, 'description' => 'D', 'icon' => 'star', 'parentUuid' => 'p-1', 'slug' => 's', 'sortOrder' => 3] + )->willReturn($this->dashboard()); + $this->dashboardService->method('findPlacements')->with(5)->willReturn([]); + + [$status, $data] = $this->outcome($this->controller()->create('Board', 'D', 'star', 'p-1', 's', 3)); + + $this->assertSame([201, ['dashboard', 'placements'], 'd-1', []], [$status, array_keys($data), $data['dashboard']['uuid'] ?? null, $data['placements'] ?? null]); + } + + public function testCreateMapsServiceErrors(): void { + $this->allowCreate(); + $calls = 0; + $this->dashboardService->method('createDashboard')->willReturnCallback(function () use (&$calls): Dashboard { + $calls++; + return match ($calls) { + 1 => $this->throwQuota(), + 2 => $this->throwInvalid(), + default => $this->throwGeneric(), + }; + }); + + $quota = $this->outcome($this->controller()->create(name: 'x')); + $invalid = $this->outcome($this->controller()->create(name: 'x')); + $generic = $this->outcome($this->controller()->create(name: 'x')); + + $this->assertSame(409, $quota[0]); + $this->assertSame([400, ['status' => 'error', 'error' => 'invalid_argument', 'message' => 'Bad parent']], $invalid); + $this->assertSame(400, $generic[0]); + } + + private function throwQuota(): never { + throw new QuotaExceededException(quota: QuotaExceededException::QUOTA_DASHBOARDS, limit: 1, current: 1); + } + + private function throwInvalid(): never { + throw new InvalidArgumentException('Bad parent'); + } + + private function throwGeneric(): never { + throw new RuntimeException('db down'); + } + + /** + * Create bodies and the parameters they resolve to. + * + * @return array,2:array}> + */ + public static function createParams(): array { + return [ + 'flat defaults' => [null, [], ['name' => 'My Dashboard', 'description' => null, 'icon' => null, 'parentUuid' => null, 'slug' => null, 'sortOrder' => 0]], + 'flat values' => ['B', ['description' => 'D', 'icon' => 7, 'parentUuid' => 'p', 'slug' => 's', 'sortOrder' => 2], ['name' => 'B', 'description' => 'D', 'icon' => 7, 'parentUuid' => 'p', 'slug' => 's', 'sortOrder' => 2]], + 'body typed' => [['name' => 'B', 'description' => 'D', 'icon' => 'i', 'parentUuid' => 'p', 'slug' => 's', 'sortOrder' => '4'], [], ['name' => 'B', 'description' => 'D', 'icon' => 'i', 'parentUuid' => 'p', 'slug' => 's', 'sortOrder' => 4]], + 'body untyped' => [['icon' => 3, 'parentUuid' => 4, 'slug' => 5, 'sortOrder' => 'x'], ['slug' => 'ignored'], ['name' => 'My Dashboard', 'description' => null, 'icon' => null, 'parentUuid' => null, 'slug' => null, 'sortOrder' => 0]], + 'body empty' => [[], [], ['name' => 'My Dashboard', 'description' => null, 'icon' => null, 'parentUuid' => null, 'slug' => null, 'sortOrder' => 0]], + ]; + } + + /** + * @dataProvider createParams + */ + public function testCreateParameterResolution(mixed $name, array $flat, array $expected): void { + $method = new ReflectionMethod(DashboardApiController::class, 'resolveCreateParams'); + + $this->assertSame($expected, $method->invoke($this->controller(), $name, $flat)); + } + + /** + * Update guard cases: placements given, metadata / full permission, outcome. + * + * @return array + */ + public static function updateGuards(): array { + return [ + 'metadata allowed' => [null, true, false, 'applied'], + 'metadata refused' => [null, false, true, 'forbidden'], + 'layout allowed' => [[['id' => 1]], false, true, 'applied'], + 'layout refused' => [[['id' => 1]], true, false, 'forbidden'], + ]; + } + + /** + * @dataProvider updateGuards + */ + public function testUpdateGuard(?array $placements, bool $metadata, bool $full, string $expected): void { + $this->permissionService->method('canEditDashboardMetadata')->willReturn($metadata); + $this->permissionService->method('canEditDashboard')->willReturn($full); + $this->dashboardService->expects($expected === 'applied' ? $this->once() : $this->never()) + ->method('updateDashboard')->willReturn($this->dashboard()); + + $outcome = $this->outcome($this->controller()->update(id: 5, name: 'N', placements: $placements)); + + if ($expected === 'forbidden') { + $this->assertSame([403, ['error' => 'Access denied']], $outcome); + } + } + + public function testUpdateWithoutLoginIsRefused(): void { + $this->permissionService->expects($this->never())->method('canEditDashboardMetadata'); + + $this->assertSame([401, ['error' => 'Not logged in']], $this->outcome($this->controller(null)->update(id: 5))); + } + + public function testGroupUpdateOutcomes(): void { + $this->dashboardService->expects($this->exactly(3))->method('updateGroupShared')->willReturnCallback( + function (string $actorUserId, string $groupId, string $uuid, array $patch): Dashboard { + if ($uuid === 'gone') { + throw new DoesNotExistException('x'); + } + + if ($uuid === 'bad') { + throw new Exception('nope'); + } + + $this->assertSame(['alice', 'sales', ['name' => 'N', 'gridColumns' => 6]], [$actorUserId, $groupId, $patch]); + return $this->dashboard(); + } + ); + + $this->assertSame([401, ['error' => 'Not logged in']], $this->outcome($this->controller(null)->updateGroup('sales', 'g'))); + $this->assertSame(200, $this->controller()->updateGroup('sales', 'g', 'N', null, 6)->getStatus()); + $this->assertSame(404, $this->controller()->updateGroup('sales', 'gone')->getStatus()); + $this->assertSame([400, ['error' => 'Operation failed']], $this->outcome($this->controller()->updateGroup('sales', 'bad'))); + } + + public function testGroupDefaultOutcomes(): void { + $this->dashboardService->expects($this->exactly(3))->method('setGroupDefault')->willReturnCallback( + static function (string $actorUserId, string $groupId, string $uuid): void { + if ($uuid === 'gone') { + throw new DoesNotExistException('x'); + } + + if ($uuid === 'bad') { + throw new Exception('nope'); + } + } + ); + + $this->assertSame([401, ['error' => 'Not logged in']], $this->outcome($this->controller(null)->setGroupDefault('sales', 'g'))); + $this->assertSame(400, $this->controller()->setGroupDefault('sales')->getStatus()); + $this->assertSame(400, $this->controller()->setGroupDefault('sales', '')->getStatus()); + $this->assertSame([200, ['status' => 'ok', 'groupId' => 'sales', 'uuid' => 'g']], $this->outcome($this->controller()->setGroupDefault('sales', 'g'))); + $this->assertSame(404, $this->controller()->setGroupDefault('sales', 'gone')->getStatus()); + $this->assertSame([400, ['error' => 'Operation failed']], $this->outcome($this->controller()->setGroupDefault('sales', 'bad'))); + } + + public function testForkOutcomes(): void { + $this->dashboardService->method('forkAsPersonal')->willReturnCallback(function (string $userId, string $sourceUuid, ?string $name): Dashboard { + return match ($sourceUuid) { + 'off' => throw new PersonalDashboardsDisabledException(), + 'full' => throw new QuotaExceededException(quota: QuotaExceededException::QUOTA_DASHBOARDS, limit: 1, current: 1), + 'gone' => throw new DoesNotExistException('x'), + 'boom' => throw new RuntimeException('disk'), + default => $this->dashboard('f-' . $name), + }; + }); + $this->logger->expects($this->once())->method('error')->with( + 'launchpad: fork failed for user {user}: {message}', + ['user' => 'alice', 'message' => 'disk'] + ); + + $this->assertSame([401, ['error' => 'Not logged in']], $this->outcome($this->controller(null)->fork('s'))); + [$status, $data] = $this->outcome($this->controller()->fork('s', 'copy')); + $this->assertSame([201, 'success', 'f-copy'], [$status, $data['status'], $data['dashboard']['uuid']]); + $this->assertSame(403, $this->controller()->fork('off')->getStatus()); + $this->assertSame(409, $this->controller()->fork('full')->getStatus()); + $this->assertSame([404, ['status' => 'error', 'error' => 'not_found', 'message' => 'not_found']], $this->normaliseNotFound($this->outcome($this->controller()->fork('gone')))); + $this->assertSame([500, ['status' => 'error', 'error' => 'internal_error', 'message' => 'An unexpected error occurred']], $this->outcome($this->controller()->fork('boom'))); + } + + private function normaliseNotFound(array $outcome): array { + $outcome[1]['message'] = $outcome[1]['error'] ?? null; + return [$outcome[0], ['status' => $outcome[1]['status'] ?? null, 'error' => $outcome[1]['error'] ?? null, 'message' => $outcome[1]['message']]]; + } + + public function testScheduleOutcomes(): void { + $this->dashboardService->method('schedule')->willReturnCallback(function (string $uuid, ?string $publishAt, string $userId, ?string $unpublishAt): Dashboard { + return match ($uuid) { + 'gone' => throw new DoesNotExistException('x'), + 'past' => throw new InvalidArgumentException('in the past'), + 'owner' => throw new Exception(DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN), + 'boom' => throw new Exception('other'), + default => $this->dashboard($uuid . '|' . $publishAt . '|' . $unpublishAt), + }; + }); + + $this->assertSame([401, ['error' => 'Not logged in']], $this->outcome($this->controller(null)->schedule('d', '2030-01-01'))); + $this->assertSame([400, ['status' => 'error', 'error' => 'invalid_argument', 'message' => DashboardService::ERR_SCHEDULE_PAST_DATE]], $this->outcome($this->controller()->schedule('d', '', null))); + $this->assertSame('d||2030-02-01', $this->controller()->schedule('d', null, '2030-02-01')->getData()['dashboard']['uuid']); + $this->assertSame('d|2030-01-01|', $this->controller()->schedule('d', '2030-01-01')->getData()['dashboard']['uuid']); + $this->assertSame(404, $this->controller()->schedule('gone', '2030-01-01')->getStatus()); + $this->assertSame([400, ['status' => 'error', 'error' => 'invalid_argument', 'message' => 'in the past']], $this->outcome($this->controller()->schedule('past', '2030-01-01'))); + $this->assertSame([403, ['error' => DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN]], $this->outcome($this->controller()->schedule('owner', '2030-01-01'))); + $this->assertSame([400, ['error' => 'Operation failed']], $this->outcome($this->controller()->schedule('boom', '2030-01-01'))); + } + + public function testBreadcrumbsHideWhatTheViewerCannotSee(): void { + $method = new ReflectionMethod(DashboardApiController::class, 'visibleBreadcrumbs'); + $this->dashboardService->method('getVisibleToUser')->willReturn([ + ['dashboard' => $this->dashboard('root'), 'source' => 'user'], + ['dashboard' => $this->dashboard('mid'), 'source' => 'user'], + ]); + $this->treeService->method('computeBreadcrumbs')->willReturn([ + ['uuid' => 'root', 'name' => 'Root', 'slug' => 'r'], + ['uuid' => 'secret', 'name' => 'Secret', 'slug' => 's'], + ['name' => 'No uuid'], + ['uuid' => 'mid', 'name' => 'Mid', 'slug' => 'm', 'hidden' => 'kept'], + ]); + + $this->assertSame([], $method->invoke($this->controller(), $this->dashboard('leaf', null), 'alice')); + $this->assertSame([], $method->invoke($this->controller(), $this->dashboard('leaf', ''), 'alice')); + $this->assertSame( + [ + ['uuid' => 'root', 'name' => 'Root', 'slug' => 'r', 'hidden' => false], + ['uuid' => null, 'name' => null, 'slug' => null, 'hidden' => true], + ['uuid' => null, 'name' => null, 'slug' => null, 'hidden' => true], + ['uuid' => 'mid', 'name' => 'Mid', 'slug' => 'm', 'hidden' => 'kept'], + ], + $method->invoke($this->controller(), $this->dashboard('leaf', 'mid'), 'alice') + ); + } +}//end class From d24ff6893ef8c03eeaffb31cfa24655386cd4da5 Mon Sep 17 00:00:00 2001 From: Juan Claude Date: Wed, 11 Nov 2026 18:35:54 +0000 Subject: [PATCH 2/2] refactor(dashboard-api): split DashboardApiController create, update guard, group, fork and schedule actions into named steps Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Controller/DashboardApiController.php | 577 ++++++++++++++------ lib/Controller/DashboardScheduleRequest.php | 45 ++ lib/Controller/GroupDashboardTarget.php | 43 ++ 3 files changed, 486 insertions(+), 179 deletions(-) create mode 100644 lib/Controller/DashboardScheduleRequest.php create mode 100644 lib/Controller/GroupDashboardTarget.php diff --git a/lib/Controller/DashboardApiController.php b/lib/Controller/DashboardApiController.php index 9415517b7..ac79c4170 100644 --- a/lib/Controller/DashboardApiController.php +++ b/lib/Controller/DashboardApiController.php @@ -359,23 +359,48 @@ private function visibleBreadcrumbs(Dashboard $dashboard, string $userId): array return []; } + $visible = $this->visibleUuids(userId: $userId); + + $crumbs = []; + foreach ($this->history->treeService->computeBreadcrumbs(uuid: (string)$dashboard->getUuid()) as $crumb) { + $crumbs[] = self::crumbFor(crumb: $crumb, visible: $visible); + } + + return $crumbs; + }//end visibleBreadcrumbs() + + /** + * The UUIDs of every dashboard visible to a user, keyed for `isset()`. + * + * @param string $userId The viewing user. + * + * @return array The visible UUIDs. + */ + private function visibleUuids(string $userId): array { $visible = []; foreach ($this->services->dashboardService->getVisibleToUser(userId: $userId) as $entry) { $visible[(string)$entry['dashboard']->getUuid()] = true; } - $crumbs = []; - foreach ($this->history->treeService->computeBreadcrumbs(uuid: (string)$dashboard->getUuid()) as $crumb) { - if (isset($visible[(string)($crumb['uuid'] ?? '')]) === true) { - $crumbs[] = $crumb + ['hidden' => false]; - continue; - } + return $visible; + }//end visibleUuids() - $crumbs[] = ['uuid' => null, 'name' => null, 'slug' => null, 'hidden' => true]; + /** + * One breadcrumb as the viewer may see it: as is when its dashboard is + * visible, otherwise a hidden placeholder. + * + * @param array $crumb The breadcrumb. + * @param array $visible The visible UUIDs. + * + * @return array The breadcrumb to answer with. + */ + private static function crumbFor(array $crumb, array $visible): array { + if (isset($visible[(string)($crumb['uuid'] ?? '')]) === true) { + return $crumb + ['hidden' => false]; } - return $crumbs; - }//end visibleBreadcrumbs() + return ['uuid' => null, 'name' => null, 'slug' => null, 'hidden' => true]; + }//end crumbFor() /** * Create a new dashboard. @@ -404,7 +429,7 @@ public function create( ?string $slug = null, ?int $sortOrder = null, ): JSONResponse { - $denial = $this->denyCreate(); + $denial = $this->denyCreateRequest(); if ($denial !== null) { return $denial; } @@ -420,38 +445,37 @@ public function create( ] ); - $permError = $this->checkCreatePermissions( + return $this->createFromParams(resolved: $resolved); + }//end create() + + /** + * The refusal of a create request: the guard chain of + * {@see self::denyCreate()}, then the create permissions. + * + * @return JSONResponse|null The refusal, or null to proceed. + */ + private function denyCreateRequest(): ?JSONResponse { + $denial = $this->denyCreate(); + if ($denial !== null) { + return $denial; + } + + return $this->checkCreatePermissions( userId: $this->userId ); - if ($permError !== null) { - return $permError; - } + }//end denyCreateRequest() + /** + * Create the dashboard from resolved parameters and answer 201, or map + * the service's refusal to its response. + * + * @param array $resolved The resolved create parameters. + * + * @return JSONResponse The created dashboard, or the error. + */ + private function createFromParams(array $resolved): JSONResponse { try { - $dashboard = $this->services->dashboardService->createDashboard( - userId: $this->userId, - name: $resolved['name'], - options: ['seedDefaults' => true] + array_diff_key($resolved, ['name' => true]) - ); - - // The newly-created dashboard ships with a default widget - // bundle (Conduction + Sendent + Nextcloud tiles + a Files - // widget) seeded by the service. Returning the placements - // here matches the `getActive()` envelope so the store can - // populate `widgetPlacements` without an extra round-trip. - $placements = $this->services->dashboardService->findPlacements( - dashboardId: $dashboard->getId() - ); - - return ResponseHelper::success( - data: [ - 'dashboard' => $dashboard->jsonSerialize(), - 'placements' => ResponseHelper::serializeList( - entities: $placements - ), - ], - statusCode: Http::STATUS_CREATED - ); + return $this->createdResponse(dashboard: $this->createDashboardFrom(resolved: $resolved)); } catch (QuotaExceededException $e) { // Dashboard-quota-limits REQ-QUOTA-002: the user is at their // dashboard limit — HTTP 409 with the structured body. @@ -463,7 +487,52 @@ public function create( } catch (\Exception $e) { return ResponseHelper::error(exception: $e); }//end try - }//end create() + }//end createFromParams() + + /** + * Create the signed-in user's dashboard, seeded with the default + * widgets, from resolved parameters. + * + * @param array $resolved The resolved create parameters. + * + * @return Dashboard The created dashboard. + */ + private function createDashboardFrom(array $resolved): Dashboard { + return $this->services->dashboardService->createDashboard( + userId: $this->userId, + name: $resolved['name'], + options: ['seedDefaults' => true] + array_diff_key($resolved, ['name' => true]) + ); + }//end createDashboardFrom() + + /** + * Answer 201 with a newly-created dashboard and its placements. + * + * The newly-created dashboard ships with a default widget + * bundle (Conduction + Sendent + Nextcloud tiles + a Files + * widget) seeded by the service. Returning the placements + * here matches the `getActive()` envelope so the store can + * populate `widgetPlacements` without an extra round-trip. + * + * @param Dashboard $dashboard The created dashboard. + * + * @return JSONResponse The 201 response. + */ + private function createdResponse(Dashboard $dashboard): JSONResponse { + $placements = $this->services->dashboardService->findPlacements( + dashboardId: $dashboard->getId() + ); + + return ResponseHelper::success( + data: [ + 'dashboard' => $dashboard->jsonSerialize(), + 'placements' => ResponseHelper::serializeList( + entities: $placements + ), + ], + statusCode: Http::STATUS_CREATED + ); + }//end createdResponse() /** * Resolve the authentication / authorisation guard chain for @@ -560,29 +629,38 @@ private function denyUpdate(int $dashboardId, bool $metadataOnly): ?JSONResponse return $refusal; } - // REQ-PERM-007: Metadata-only updates (name, description, icon) are - // allowed for all permission levels. Widget/tile/layout changes - // require add_only or full permission. - if ($metadataOnly === true - && $this->permissionService->canEditDashboardMetadata( - userId: $this->userId, - dashboardId: $dashboardId - ) === false - ) { + if ($this->mayUpdate(dashboardId: $dashboardId, metadataOnly: $metadataOnly) === false) { return ResponseHelper::forbidden(); } - if ($metadataOnly === false - && $this->permissionService->canEditDashboard( + return null; + }//end denyUpdate() + + /** + * Whether the user may make this kind of update. + * + * REQ-PERM-007: Metadata-only updates (name, description, icon) are + * allowed for all permission levels. Widget/tile/layout changes + * require add_only or full permission. + * + * @param int $dashboardId The dashboard ID. + * @param bool $metadataOnly Whether the update leaves the placements alone. + * + * @return bool True when the update is allowed. + */ + private function mayUpdate(int $dashboardId, bool $metadataOnly): bool { + if ($metadataOnly === true) { + return $this->permissionService->canEditDashboardMetadata( userId: $this->userId, dashboardId: $dashboardId - ) === false - ) { - return ResponseHelper::forbidden(); + ) !== false; } - return null; - }//end denyUpdate() + return $this->permissionService->canEditDashboard( + userId: $this->userId, + dashboardId: $dashboardId + ) !== false; + }//end mayUpdate() /** * Apply an allowed update and answer with the saved dashboard. @@ -1016,35 +1094,67 @@ public function updateGroup( ?int $gridColumns = null, ?array $placements = null, ): JSONResponse { + $patch = $this->buildGroupUpdateData(name: $name, description: $description, gridColumns: $gridColumns, placements: $placements); + + return $this->updateGroupDashboard(target: new GroupDashboardTarget(groupId: $groupId, uuid: $uuid), patch: $patch); + }//end updateGroup() + + /** + * Apply a group-shared patch for the signed-in user. + * + * @param GroupDashboardTarget $target The group and dashboard UUID. + * @param array $patch The non-null patch fields. + * + * @return JSONResponse The updated dashboard, or the error. + */ + private function updateGroupDashboard(GroupDashboardTarget $target, array $patch): JSONResponse { if ($this->userId === null) { return ResponseHelper::unauthorized(); } - $patch = $this->buildGroupUpdateData( - name: $name, - description: $description, - gridColumns: $gridColumns, - placements: $placements + return $this->runGroupMutation( + mutation: fn (): JSONResponse => $this->dashboardResponse( + dashboard: $this->services->dashboardService->updateGroupShared( + actorUserId: $this->userId, + groupId: $target->groupId, + uuid: $target->uuid, + patch: $patch + ) + ) ); + }//end updateGroupDashboard() + /** + * Run a group-shared mutation, answering an unknown dashboard with 404 + * and any other failure with the generic error. + * + * @param callable(): JSONResponse $mutation The mutation and its answer. + * + * @return JSONResponse The mutation's answer, or the error. + */ + private function runGroupMutation(callable $mutation): JSONResponse { try { - $dashboard = $this->services->dashboardService->updateGroupShared( - actorUserId: $this->userId, - groupId: $groupId, - uuid: $uuid, - patch: $patch - ); - - return ResponseHelper::success( - data: ['dashboard' => $dashboard->jsonSerialize()] - ); + return $mutation(); } catch (DoesNotExistException) { // ADR-005: do not leak raw exception messages to clients. return $this->failure(message: 'Dashboard not found', statusCode: Http::STATUS_NOT_FOUND); } catch (\Exception $e) { return ResponseHelper::error(exception: $e); }//end try - }//end updateGroup() + }//end runGroupMutation() + + /** + * Answer 200 with one dashboard. + * + * @param Dashboard $dashboard The dashboard. + * + * @return JSONResponse The response. + */ + private function dashboardResponse(Dashboard $dashboard): JSONResponse { + return ResponseHelper::success( + data: ['dashboard' => $dashboard->jsonSerialize()] + ); + }//end dashboardResponse() /** * Delete a group-shared dashboard. Admin-only. @@ -1108,36 +1218,51 @@ public function setGroupDefault( } if ($uuid === null || $uuid === '') { - return ResponseHelper::error( - exception: new InvalidArgumentException( - 'Missing required field: uuid' - ), - statusCode: Http::STATUS_BAD_REQUEST - ); + return self::missingUuid(); } - try { - $this->services->dashboardService->setGroupDefault( - actorUserId: $this->userId, - groupId: $groupId, - uuid: $uuid - ); - - return ResponseHelper::success( - data: [ - 'status' => 'ok', - 'groupId' => $groupId, - 'uuid' => $uuid, - ] - ); - } catch (DoesNotExistException) { - // ADR-005: do not leak raw exception messages to clients. - return $this->failure(message: 'Dashboard not found', statusCode: Http::STATUS_NOT_FOUND); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - }//end try + return $this->runGroupMutation( + mutation: fn (): JSONResponse => $this->markGroupDefault(target: new GroupDashboardTarget(groupId: $groupId, uuid: $uuid)) + ); }//end setGroupDefault() + /** + * The 400 answer for a group-default request without a UUID. + * + * @return JSONResponse The response. + */ + private static function missingUuid(): JSONResponse { + return ResponseHelper::error( + exception: new InvalidArgumentException( + 'Missing required field: uuid' + ), + statusCode: Http::STATUS_BAD_REQUEST + ); + }//end missingUuid() + + /** + * Make a group-shared dashboard its group's default and answer 200. + * + * @param GroupDashboardTarget $target The group and dashboard UUID. + * + * @return JSONResponse The response. + */ + private function markGroupDefault(GroupDashboardTarget $target): JSONResponse { + $this->services->dashboardService->setGroupDefault( + actorUserId: $this->userId, + groupId: $target->groupId, + uuid: $target->uuid + ); + + return ResponseHelper::success( + data: [ + 'status' => 'ok', + 'groupId' => $target->groupId, + 'uuid' => $target->uuid, + ] + ); + }//end markGroupDefault() + /** * Persist the user's active-dashboard preference. * @@ -1265,6 +1390,19 @@ public function fork( return ResponseHelper::unauthorized(); } + return $this->forkFor(uuid: $uuid, name: $name); + }//end fork() + + /** + * Fork a dashboard for the signed-in user and answer 201, or map the + * failure to its response. + * + * @param string $uuid The source dashboard UUID. + * @param string|null $name Optional name for the copy. + * + * @return JSONResponse The fork, or the error. + */ + private function forkFor(string $uuid, ?string $name): JSONResponse { try { $fork = $this->services->dashboardService->forkAsPersonal( userId: $this->userId, @@ -1272,44 +1410,75 @@ public function fork( name: $name ); - return new JSONResponse( - data: [ - 'status' => 'success', - 'dashboard' => $fork->jsonSerialize(), - ], - statusCode: Http::STATUS_CREATED - ); - } catch (PersonalDashboardsDisabledException $e) { - return $this->personalDashboardsDisabled(exception: $e); - } catch (QuotaExceededException $e) { - // Dashboard-quota-limits REQ-QUOTA-002: a fork is bound by the - // per-user dashboard quota — HTTP 409 with the structured body. - return ResponseHelper::quotaExceeded(exception: $e); - } catch (DoesNotExistException) { - // REQ-DASH-020: source not visible — 404 without leaking - // existence (use the canonical message rather than echoing - // the exception detail). - return $this->errorCode(code: 'not_found', statusCode: Http::STATUS_NOT_FOUND); + return self::forkCreated(fork: $fork); } catch (\Throwable $t) { - // REQ-DASH-021 + ADR-005: log the real cause, return a - // stable, generic envelope to the client. - $this->history->logger->error( - message: 'launchpad: fork failed for user {user}: {message}', - context: [ - 'user' => $this->userId, - 'message' => $t->getMessage(), - ] - ); - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'internal_error', - 'message' => 'An unexpected error occurred', - ], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - }//end try - }//end fork() + return $this->forkFailure(throwable: $t); + } + }//end forkFor() + + /** + * Answer 201 with a fork. + * + * @param Dashboard $fork The personal copy. + * + * @return JSONResponse The response. + */ + private static function forkCreated(Dashboard $fork): JSONResponse { + return new JSONResponse( + data: [ + 'status' => 'success', + 'dashboard' => $fork->jsonSerialize(), + ], + statusCode: Http::STATUS_CREATED + ); + }//end forkCreated() + + /** + * Map a failed fork to its response. + * + * Personal dashboards switched off answer 403; dashboard-quota-limits + * REQ-QUOTA-002: a fork is bound by the per-user dashboard quota — + * HTTP 409 with the structured body; REQ-DASH-020: a source that is + * not visible answers 404 without leaking existence (the canonical + * message rather than the exception detail); anything else is + * logged and answered with a stable, generic envelope (REQ-DASH-021 + * + ADR-005). + * + * @param \Throwable $throwable What the fork threw. + * + * @return JSONResponse The error response. + */ + private function forkFailure(\Throwable $throwable): JSONResponse { + return match (true) { + $throwable instanceof PersonalDashboardsDisabledException => $this->personalDashboardsDisabled(exception: $throwable), + $throwable instanceof QuotaExceededException => ResponseHelper::quotaExceeded(exception: $throwable), + $throwable instanceof DoesNotExistException => $this->errorCode(code: 'not_found', statusCode: Http::STATUS_NOT_FOUND), + default => $this->internalForkError(throwable: $throwable), + }; + }//end forkFailure() + + /** + * Log the real cause of a failed fork and answer 500 with the generic + * envelope. + * + * @param \Throwable $throwable What the fork threw. + * + * @return JSONResponse The 500 response. + */ + private function internalForkError(\Throwable $throwable): JSONResponse { + $this->history->logger->error( + message: 'launchpad: fork failed for user {user}: {message}', + context: [ + 'user' => $this->userId, + 'message' => $throwable->getMessage(), + ] + ); + + return new JSONResponse( + data: ['status' => 'error', 'error' => 'internal_error', 'message' => 'An unexpected error occurred'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end internalForkError() /** * Publish a dashboard. REQ-DASH-032. @@ -1471,22 +1640,38 @@ public function schedule( return $refusal; } + return $this->scheduleDated( + request: new DashboardScheduleRequest(uuid: $uuid, publishAt: $publishAt, unpublishAt: $unpublishAt) + ); + }//end schedule() + + /** + * Refuse a schedule without any time, then schedule. + * + * @param DashboardScheduleRequest $request The dashboard and its times. + * + * @return JSONResponse The updated dashboard payload, or the refusal. + */ + private function scheduleDated(DashboardScheduleRequest $request): JSONResponse { // A take-down time alone is a valid schedule (REQ-SCHEDUI-002). - if ($this->isBlank(value: $publishAt) === true && $this->isBlank(value: $unpublishAt) === true) { + if ($this->isBlank(value: $request->publishAt) === true && $this->isBlank(value: $request->unpublishAt) === true) { return ResponseHelper::invalidArgument(message: DashboardService::ERR_SCHEDULE_PAST_DATE); } - try { - $dashboard = $this->services->dashboardService->schedule( - uuid: $uuid, - publishAt: $publishAt, - userId: $this->userId, - unpublishAt: $unpublishAt - ); + return $this->scheduled(request: $request); + }//end scheduleDated() - return ResponseHelper::success( - data: ['dashboard' => $dashboard->jsonSerialize()] - ); + /** + * Schedule the dashboard and answer with it, mapping the service's + * refusals to their responses. + * + * @param DashboardScheduleRequest $request The dashboard and its times. + * + * @return JSONResponse The updated dashboard payload, or the error. + */ + private function scheduled(DashboardScheduleRequest $request): JSONResponse { + try { + return $this->dashboardResponse(dashboard: $this->scheduleFor(request: $request)); } catch (DoesNotExistException) { return $this->errorCode(code: 'not_found', statusCode: Http::STATUS_NOT_FOUND); } catch (InvalidArgumentException $e) { @@ -1494,7 +1679,23 @@ public function schedule( } catch (\Exception $e) { return $this->mapPublicationError(exception: $e); }//end try - }//end schedule() + }//end scheduled() + + /** + * Schedule the dashboard on behalf of the signed-in user. + * + * @param DashboardScheduleRequest $request The dashboard and its times. + * + * @return Dashboard The scheduled dashboard. + */ + private function scheduleFor(DashboardScheduleRequest $request): Dashboard { + return $this->services->dashboardService->schedule( + uuid: $request->uuid, + publishAt: $request->publishAt, + userId: $this->userId, + unpublishAt: $request->unpublishAt + ); + }//end scheduleFor() /** * Whether a request value is missing or empty. @@ -1606,39 +1807,8 @@ private function mapPublicationError(\Exception $exception): JSONResponse { */ private function resolveCreateParams($name, array $flat=[]): array { if (is_array($name) === true) { - $bodyIcon = ($name['icon'] ?? null); - $resolvedIcon = null; - if (is_string($bodyIcon) === true) { - $resolvedIcon = $bodyIcon; - } - - $bodyParent = ($name['parentUuid'] ?? null); - $bodySlug = ($name['slug'] ?? null); - $bodySort = ($name['sortOrder'] ?? null); - $resolvedParent = null; - if (is_string($bodyParent) === true) { - $resolvedParent = $bodyParent; - } - - $resolvedSlug = null; - if (is_string($bodySlug) === true) { - $resolvedSlug = $bodySlug; - } - - $resolvedSort = 0; - if (is_numeric($bodySort) === true) { - $resolvedSort = (int)$bodySort; - } - - return [ - 'name' => $name['name'] ?? 'My Dashboard', - 'description' => $name['description'] ?? null, - 'icon' => $resolvedIcon, - 'parentUuid' => $resolvedParent, - 'slug' => $resolvedSlug, - 'sortOrder' => $resolvedSort, - ]; - }//end if + return self::resolveCreateBody(body: $name); + } return [ 'name' => $name ?? 'My Dashboard', @@ -1650,6 +1820,55 @@ private function resolveCreateParams($name, array $flat=[]): array { ]; }//end resolveCreateParams() + /** + * Resolve create parameters from a JSON body: string fields keep only + * strings, the sort order only numbers. + * + * @param array $body The decoded request body. + * + * @return array The resolved parameters. + */ + private static function resolveCreateBody(array $body): array { + return [ + 'name' => $body['name'] ?? 'My Dashboard', + 'description' => $body['description'] ?? null, + 'icon' => self::stringOrNull(value: ($body['icon'] ?? null)), + 'parentUuid' => self::stringOrNull(value: ($body['parentUuid'] ?? null)), + 'slug' => self::stringOrNull(value: ($body['slug'] ?? null)), + 'sortOrder' => self::intOrZero(value: ($body['sortOrder'] ?? null)), + ]; + }//end resolveCreateBody() + + /** + * A string value as is, anything else as null. + * + * @param mixed $value The body value. + * + * @return string|null The string, or null. + */ + private static function stringOrNull(mixed $value): ?string { + if (is_string($value) === true) { + return $value; + } + + return null; + }//end stringOrNull() + + /** + * A numeric value as an integer, anything else as 0. + * + * @param mixed $value The body value. + * + * @return int The integer. + */ + private static function intOrZero(mixed $value): int { + if (is_numeric($value) === true) { + return (int)$value; + } + + return 0; + }//end intOrZero() + /** * Read the case-insensitive `cascade` query param (REQ-DASH-030). * diff --git a/lib/Controller/DashboardScheduleRequest.php b/lib/Controller/DashboardScheduleRequest.php new file mode 100644 index 000000000..33b70e101 --- /dev/null +++ b/lib/Controller/DashboardScheduleRequest.php @@ -0,0 +1,45 @@ + + * @copyright 2026 Conduction b.v. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT:auto + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/specs/dashboards/spec.md + */ + +declare(strict_types=1); + +namespace OCA\LaunchPad\Controller; + +/** + * The dashboard and the publish / take-down times of one schedule request. + * + * @spec openspec/specs/dashboards/spec.md + */ +final class DashboardScheduleRequest { + /** + * Constructor. + * + * @param string $uuid The dashboard UUID from the URL. + * @param string|null $publishAt The publication time, if any. + * @param string|null $unpublishAt The take-down time, if any (REQ-SCHEDUI-002). + */ + public function __construct( + public readonly string $uuid, + public readonly ?string $publishAt, + public readonly ?string $unpublishAt, + ) { + }//end __construct() +}//end class diff --git a/lib/Controller/GroupDashboardTarget.php b/lib/Controller/GroupDashboardTarget.php new file mode 100644 index 000000000..428475c05 --- /dev/null +++ b/lib/Controller/GroupDashboardTarget.php @@ -0,0 +1,43 @@ + + * @copyright 2026 Conduction b.v. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT:auto + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/specs/dashboards/spec.md + */ + +declare(strict_types=1); + +namespace OCA\LaunchPad\Controller; + +/** + * The group and UUID of the group-shared dashboard a group action addresses. + * + * @spec openspec/specs/dashboards/spec.md + */ +final class GroupDashboardTarget { + /** + * Constructor. + * + * @param string $groupId The group ID from the URL. + * @param string $uuid The dashboard UUID from the URL. + */ + public function __construct( + public readonly string $groupId, + public readonly string $uuid, + ) { + }//end __construct() +}//end class