From 695e3389527c333b60d83158d088785d7c10a1bf Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 5 May 2026 20:06:30 +0200 Subject: [PATCH 1/2] feat(deep-link): bidirectional URL sync for dashboards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dashboard now has its own URL — `/apps/mydash/{slug}` (slug-chains work for nested dashboards: `/apps/mydash/finance/q1`). Visiting a deep-link lands the workspace on the matching dashboard; switching dashboards in the sidebar pushes a history entry so back/forward navigates between dashboards. Stale or unknown slugs fall back silently to the seven-step resolver rather than 404 — old bookmarks always land on something. Backend: - Catch-all route `page#deepLink` on `/{deepLink}` with a negative- lookahead requirement that excludes `/api/...`. Registered last so every literal route wins first. - `PageController::deepLink` delegates to a refactored `index($deepLink)` that resolves the slug-chain through `DashboardTreeService`, falls back to the resolver on failure (logged), and pushes the canonical path (computed via `computePath`) into initial state as `deepLinkPath` so the frontend can normalise the URL in-place. - New `GET /api/dashboards/{uuid}/path` endpoint returns the canonical slug-chain for a UUID. Empty path is a valid response (NULL slugs are legal but unaddressable) — frontend treats it as "leave the URL alone". Frontend: - `loadInitialState` reads the optional `deepLinkPath` key with empty- string default for backwards-compat with older deploys. - Views.vue replaces the URL on mount via `history.replaceState` to match what the server actually rendered, then watches `activeDashboard.uuid` and pushes a new history entry on every switch. - `popstate` listener strips the route prefix and re-resolves via the existing `getDashboardByPath` API, then `switchDashboard()`. Tests: - New `DashboardApiControllerComputePathTest` (4 cases) pinning the canonical-path endpoint contract. - Newman: deep-link page render with known + unknown slugs (silent- fallback contract); regression check that `/api/health` still routes past the catch-all; canonical-path API envelope shape; empty-path empty-uuid contract. --- appinfo/routes.php | 15 ++ lib/Controller/DashboardApiController.php | 42 +++++ lib/Controller/PageController.php | 112 +++++++++++- lib/Service/InitialStateBuilder.php | 20 +++ src/services/api.js | 9 + src/utils/loadInitialState.js | 8 + src/views/Views.vue | 150 +++++++++++++++- .../DashboardApiControllerComputePathTest.php | 164 ++++++++++++++++++ tests/integration/local.env.json | 1 + .../mydash.postman_collection.json | 157 +++++++++++++++++ 10 files changed, 670 insertions(+), 8 deletions(-) create mode 100644 tests/Unit/Controller/DashboardApiControllerComputePathTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 8d5e7c6a..b899e7bf 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -456,5 +456,20 @@ ['name' => 'admin_demo_showcases#destroy', 'url' => '/api/admin/demo-showcases/{id}', 'verb' => 'DELETE', 'requirements' => ['id' => '[a-z0-9\-]+']], + + // Resolve a dashboard's canonical slug-chain path (used by the + // frontend for outbound URL sync after a sidebar switch). + // Registered BEFORE the catch-all deep-link route so the literal + // `/api/dashboards/{uuid}/path` segment is matched first. + ['name' => 'dashboard_api#computePath', 'url' => '/api/dashboards/{uuid}/path', 'verb' => 'GET', + 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']], + + // Deep-link slug-chain → dashboard. MUST be the last route in the + // table so every literal `/api/...` and explicit page route is + // matched first. The negative-lookahead requirement keeps this + // catch-all from swallowing API requests if a future API route is + // inadvertently added below. + ['name' => 'page#deepLink', 'url' => '/{deepLink}', 'verb' => 'GET', + 'requirements' => ['deepLink' => '(?!api(?:/|$)).+']], ], ]; diff --git a/lib/Controller/DashboardApiController.php b/lib/Controller/DashboardApiController.php index 76a15e2e..8c9e7ae0 100644 --- a/lib/Controller/DashboardApiController.php +++ b/lib/Controller/DashboardApiController.php @@ -587,6 +587,48 @@ public function byPath(string $path=''): JSONResponse ); }//end byPath() + /** + * GET /api/dashboards/{uuid}/path — return a dashboard's canonical + * slug-chain path. + * + * Used by the frontend after every sidebar switch to keep the + * browser URL in sync with the active dashboard. The path is the + * leading-slash slug-chain returned by + * {@see DashboardTreeService::computePath()}; an empty string means + * the UUID does not resolve OR the dashboard has no slug (legal — + * NULL slugs are simply unaddressable by path), and the frontend + * treats either case as "leave the URL alone". + * + * @param string $uuid Dashboard UUID captured from the URL. + * + * @return JSONResponse `{path: string}` envelope (always 200 when + * authorised — the empty-path case is a valid + * response shape the caller distinguishes + * client-side). + */ + #[NoAdminRequired] + public function computePath(string $uuid=''): JSONResponse + { + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + if ($uuid === '') { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'missing_uuid', + 'message' => 'UUID is required', + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + return ResponseHelper::success( + data: ['path' => $this->treeService->computePath(uuid: $uuid)] + ); + }//end computePath() + /** * Activate a dashboard. * diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 5cbf759c..87896e98 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -29,6 +29,7 @@ use OCA\MyDash\Db\Dashboard; use OCA\MyDash\Service\AdminTemplateService; use OCA\MyDash\Service\DashboardService; +use OCA\MyDash\Service\DashboardTreeService; use OCA\MyDash\Service\InitialState\Page; use OCA\MyDash\Service\InitialStateBuilder; use OCA\MyDash\Service\RoleFeaturePermissionService; @@ -42,6 +43,8 @@ use OCP\IRequest; use OCP\IUserSession; use OCP\Util; +use Psr\Log\LoggerInterface; +use Throwable; /** * Workspace page controller — wires the typed initial-state contract. @@ -71,6 +74,15 @@ class PageController extends Controller * @param RoleFeaturePermissionService $roleFeaturePerm Per-user widget * allow-list source * (REQ-RFP-009..010). + * @param DashboardTreeService $treeService Slug-chain + * resolver used by the + * deep-link route. + * @param LoggerInterface $logger Used to record + * silent fallback + * when a deep-link + * path doesn't + * resolve to a + * visible dashboard. */ public function __construct( IRequest $request, @@ -81,10 +93,32 @@ public function __construct( private readonly DashboardService $dashboardService, private readonly AdminTemplateService $adminTemplateService, private readonly RoleFeaturePermissionService $roleFeaturePerm, + private readonly DashboardTreeService $treeService, + private readonly LoggerInterface $logger, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() + /** + * Deep-link entry point — `/apps/mydash/{deepLink}`. + * + * Symfony binds the captured slug-chain into `$deepLink`. Delegating + * to {@see self::index()} keeps the workspace render path single- + * sourced; the optional path argument merely overrides the active + * dashboard before initial-state assembly. + * + * @param string $deepLink Slug-chain captured from the URL (may + * contain `/` separators). + * + * @return TemplateResponse The workspace template response. + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function deepLink(string $deepLink=''): TemplateResponse + { + return $this->index(deepLink: $deepLink); + }//end deepLink() + /** * Render the workspace page. * @@ -94,11 +128,22 @@ public function __construct( * {@see \OCA\MyDash\Exception\MissingInitialStateException} so the page * never renders with a partial payload. * + * Deep-link path: when `$deepLink` resolves through the tree service + * to a dashboard the user can read, that dashboard is used as the + * active one (overriding the resolver's seven-step fallback). When + * the path doesn't resolve (renamed, deleted, never existed, or not + * visible to the caller), the controller logs a warning and falls + * back silently — bookmarks of stale slug chains still land on + * something instead of 404'ing. + * + * @param string $deepLink Optional slug-chain selecting the active + * dashboard. Empty string ⇒ default resolver. + * * @return TemplateResponse The template response. */ #[NoAdminRequired] #[NoCSRFRequired] - public function index(): TemplateResponse + public function index(string $deepLink=''): TemplateResponse { Util::addScript(application: Application::APP_ID, file: 'mydash-main'); Util::addStyle(application: Application::APP_ID, file: 'mydash'); @@ -165,15 +210,50 @@ public function index(): TemplateResponse $active = null; if ($userId !== '') { - $active = $this->dashboardService->resolveActiveDashboard( - userId: $userId, - primaryGroupId: $primaryGroupId - ); - } + // Deep-link override: when the URL carries a slug-chain we + // try to land the user on that dashboard before consulting + // the seven-step resolver. Failures (path doesn't resolve, + // not visible, throws) are swallowed so a stale bookmark + // still opens *something* instead of breaking. + if ($deepLink !== '') { + try { + $resolved = $this->treeService->resolvePath(path: $deepLink); + if ($resolved !== null) { + $active = $this->dashboardService->getDashboardForUser( + dashboardId: $resolved->getId(), + userId: $userId + ); + } + } catch (Throwable $t) { + $this->logger->warning( + message: 'mydash: deep-link resolution failed for path "{path}": {message}', + context: [ + 'path' => $deepLink, + 'message' => $t->getMessage(), + ] + ); + } + + if ($active === null) { + $this->logger->info( + message: 'mydash: deep-link path "{path}" not visible — falling back to default resolver', + context: ['path' => $deepLink] + ); + } + }//end if + + if ($active === null) { + $active = $this->dashboardService->resolveActiveDashboard( + userId: $userId, + primaryGroupId: $primaryGroupId + ); + } + }//end if $activeDashboardId = ''; $dashboardSource = Dashboard::SOURCE_GROUP; $layout = []; + $deepLinkPath = ''; if ($active !== null) { $activeDashboard = $active['dashboard']; $activeDashboardId = (string) $activeDashboard->getUuid(); @@ -187,7 +267,24 @@ public function index(): TemplateResponse }, array: $placements ); - } + // Canonical slug-chain for whatever dashboard ended up active — + // the frontend reads this to keep the URL in sync (e.g. after + // a parent rename, a stale bookmarked path is normalised + // in-place via `history.replaceState`). + try { + $deepLinkPath = $this->treeService->computePath( + uuid: (string) $activeDashboard->getUuid() + ); + } catch (Throwable $t) { + $this->logger->warning( + message: 'mydash: failed to compute path for active dashboard {uuid}: {message}', + context: [ + 'uuid' => (string) $activeDashboard->getUuid(), + 'message' => $t->getMessage(), + ] + ); + } + }//end if $allowUserDashboards = $this->dashboardService->getAllowUserDashboards(); @@ -219,6 +316,7 @@ public function index(): TemplateResponse $builder ->setAllowedWidgets($allowedWidgets) + ->setDeepLinkPath($deepLinkPath) ->apply(); // REQ-SHELL-001: pass the chrome slot ids so Nextcloud treats diff --git a/lib/Service/InitialStateBuilder.php b/lib/Service/InitialStateBuilder.php index 52fb885a..f1ef4f0d 100644 --- a/lib/Service/InitialStateBuilder.php +++ b/lib/Service/InitialStateBuilder.php @@ -288,6 +288,26 @@ public function setAllowedWidgets(?array $allowedWidgets): self return $this; }//end setAllowedWidgets() + /** + * Set the canonical slug-chain path for the active dashboard + * (workspace). + * + * Read by the frontend on mount to bring `window.location.pathname` + * in line with whichever dashboard was actually rendered — handles + * the renamed-parent / stale-bookmark cases by replacing the URL + * in-place via `history.replaceState`. Empty string when no + * dashboard is active (the page renders the empty state). + * + * @param string $deepLinkPath Canonical path or '' when none. + * + * @return self + */ + public function setDeepLinkPath(string $deepLinkPath): self + { + $this->values['deepLinkPath'] = $deepLinkPath; + return $this; + }//end setDeepLinkPath() + /** * Set every Nextcloud group (admin). * diff --git a/src/services/api.js b/src/services/api.js index f77b8c3b..7af3ca7a 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -201,6 +201,15 @@ export const api = { return axios.get(`${baseUrl}/api/dashboards/by-path/${cleanPath}`) }, + // Reverse lookup: dashboard UUID → canonical slug-chain path. Used + // by the sidebar after every switch to keep `window.location.pathname` + // in sync with the active dashboard. Server returns `{path: '...'}`; + // an empty path is a valid response (the dashboard has no slug, so + // there is no addressable URL). + getDashboardPath(uuid) { + return axios.get(`${baseUrl}/api/dashboards/${encodeURIComponent(uuid)}/path`) + }, + // REQ-LOCK-001..008: dashboard editing-lock management. // Re-entrant for the same user (a second tab refreshes the lease // instead of getting 409). Heartbeat MUST be sent every 60 s by diff --git a/src/utils/loadInitialState.js b/src/utils/loadInitialState.js index adfff2a5..b570bd3f 100644 --- a/src/utils/loadInitialState.js +++ b/src/utils/loadInitialState.js @@ -47,6 +47,14 @@ const PAGE_KEYS = { // REQ-RFP-010: list of widget IDs the caller is permitted to see. // `null` = no role-feature-permissions configured (legacy, unrestricted). allowedWidgets: null, + // Canonical slug-chain path for the active dashboard. Empty when + // no dashboard is active or the active one has no slug. The + // frontend uses this on mount to bring `window.location.pathname` + // in line with whichever dashboard the server actually rendered + // (handles renamed parents / stale bookmarks via replaceState). + // Optional key — older servers don't push it, so the default + // keeps reads typed even before the deploy lands. + deepLinkPath: '', }, admin: { allGroups: [], diff --git a/src/views/Views.vue b/src/views/Views.vue index 24476d97..5b270a0c 100644 --- a/src/views/Views.vue +++ b/src/views/Views.vue @@ -160,6 +160,7 @@ import Vue from 'vue' import { mapState, mapActions } from 'pinia' import { NcButton, NcEmptyContent } from '@conduction/nextcloud-vue' import { t } from '@nextcloud/l10n' +import { generateUrl } from '@nextcloud/router' // Icons import ViewDashboard from 'vue-material-design-icons/ViewDashboard.vue' @@ -217,6 +218,15 @@ export default { from: 'primaryGroupName', default: '', }, + // Canonical slug-chain path the server resolved for the active + // dashboard. Empty string when no dashboard is active OR the + // active one has no slug. Read once on mount to bring + // `window.location.pathname` in line with what the server + // rendered (handles renamed parents / stale bookmarks). + injectedDeepLinkPath: { + from: 'deepLinkPath', + default: '', + }, }, // Inject the typed initial-state snapshot pushed from `src/main.js` // (REQ-INIT-003..005). Defaults match the reader contract so the @@ -423,11 +433,19 @@ export default { */ 'activeDashboard.uuid': { immediate: true, - handler(uuid) { + handler(uuid, prevUuid) { if (!uuid) { return } this.recordViewEventDebounced(uuid) + // Outbound URL sync — every uuid change pushes a new + // history entry so back/forward navigates between + // dashboards. The first hydration is `replaceState` + // (handled in mounted) so we don't pollute history with + // the bootstrap entry. + if (prevUuid !== undefined) { + this.pushUrlForActiveDashboard() + } }, }, }, @@ -464,11 +482,23 @@ export default { // click closes popover). Detached in beforeDestroy so we never // leak a listener across mounts. this.grid.attach() + + // Deep-link URL sync — replace the URL in-place so the address + // bar reflects whichever dashboard the server actually rendered + // (handles renamed parents and stale bookmarked paths). Uses + // replaceState rather than pushState so the bootstrap entry + // doesn't pollute the back-button history. + this.replaceUrlFromInitialState() + + // Browser back / forward → re-resolve the URL and switch. + window.addEventListener('popstate', this.handleHistoryPopState) }, beforeDestroy() { this.grid.detach() // Drop the host pointer to avoid retaining the Vue instance. this.grid._host = null + + window.removeEventListener('popstate', this.handleHistoryPopState) }, methods: { t, @@ -783,6 +813,124 @@ export default { await this.switchDashboard(id) }, + /** + * Compute the absolute URL for a slug-chain path. The mydash + * routes mount under whatever prefix `generateUrl` produces + * (typically `/index.php/apps/mydash` or `/apps/mydash` when + * URL rewriting is enabled), so we anchor onto the same prefix + * the API client uses. + * + * @param {string} path Leading-slash slug-chain (e.g. `/finance/q1`). + * @return {string} Absolute pathname for `history.pushState`. + */ + buildDeepLinkUrl(path) { + if (!path) { + return '' + } + const prefix = generateUrl('/apps/mydash') + const cleanPath = path.startsWith('/') ? path : `/${path}` + return `${prefix}${cleanPath}` + }, + + /** + * Bring the browser URL in line with the deep-link path the + * server pushed via initial state. Runs once on mount; uses + * `replaceState` so the bootstrap entry doesn't pollute the + * back-button history. + */ + replaceUrlFromInitialState() { + const target = this.buildDeepLinkUrl(this.injectedDeepLinkPath) + if (!target) { + return + } + if (window.location.pathname.replace(/\/+$/, '') === target.replace(/\/+$/, '')) { + return + } + try { + window.history.replaceState( + { uuid: this.activeDashboard?.uuid ?? null, source: 'mydash-deeplink' }, + '', + target, + ) + } catch (e) { + // SecurityError when running outside the page's origin + // (jsdom test harnesses, sandboxed iframes). Failure is + // non-fatal — the URL just stays out of sync. + console.warn('[Views] history.replaceState failed:', e) + } + }, + + /** + * Outbound URL sync — fetch the canonical path for the active + * dashboard and `pushState` it. Called from the + * `activeDashboard.uuid` watcher AFTER the initial hydration + * (the bootstrap render uses `replaceUrlFromInitialState` + * instead). Failures are non-fatal; the URL just stays at its + * previous value while the active dashboard moves on. + */ + async pushUrlForActiveDashboard() { + const uuid = this.activeDashboard?.uuid + if (!uuid) { + return + } + try { + const res = await api.getDashboardPath(uuid) + const path = res?.data?.path ?? '' + const target = this.buildDeepLinkUrl(path) + if (!target) { + return + } + if (window.location.pathname === target) { + return + } + window.history.pushState( + { uuid, source: 'mydash-deeplink' }, + '', + target, + ) + } catch (e) { + console.warn('[Views] failed to push URL for active dashboard:', e) + } + }, + + /** + * Browser back / forward handler. Strips the mydash route + * prefix off `window.location.pathname` and re-resolves the + * remaining slug-chain via the existing by-path API. The state + * payload's uuid is preferred when present (avoids the + * round-trip), but we fall back to path resolution so external + * navigations (manually pasted URLs that hit popstate) still + * route correctly. + * + * @param {PopStateEvent} event The popstate event. + */ + async handleHistoryPopState(event) { + const targetUuid = event?.state?.uuid ?? null + if (targetUuid && targetUuid === this.activeDashboard?.uuid) { + return + } + + const prefix = generateUrl('/apps/mydash') + const pathname = window.location.pathname + let suffix = '' + if (pathname.startsWith(prefix)) { + suffix = pathname.slice(prefix.length).replace(/^\/+/, '').replace(/\/+$/, '') + } + if (!suffix) { + return + } + + try { + const res = await api.getDashboardByPath(suffix) + const dashboard = res?.data?.dashboard + if (dashboard?.id !== undefined && dashboard?.id !== null) { + await this.switchDashboard(dashboard.id) + } + } catch (e) { + console.warn('[Views] popstate path resolution failed:', e) + } + }, + /* * Wave3.6 per-row action handlers. Each cog action emits the * row's full dashboard payload (not just the active one), so diff --git a/tests/Unit/Controller/DashboardApiControllerComputePathTest.php b/tests/Unit/Controller/DashboardApiControllerComputePathTest.php new file mode 100644 index 00000000..d1df771e --- /dev/null +++ b/tests/Unit/Controller/DashboardApiControllerComputePathTest.php @@ -0,0 +1,164 @@ + + * @copyright 2026 Conduction b.v. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 MyDash Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +declare(strict_types=1); + +namespace Unit\Controller; + +use OCA\MyDash\Controller\DashboardApiController; +use OCA\MyDash\Service\AnalyticsService; +use OCA\MyDash\Service\DashboardService; +use OCA\MyDash\Service\DashboardTreeService; +use OCA\MyDash\Service\DashboardVersionService; +use OCA\MyDash\Service\PermissionService; +use OCP\AppFramework\Http; +use OCP\IRequest; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Unit tests for the canonical-path lookup endpoint. + */ +class DashboardApiControllerComputePathTest extends TestCase +{ + + /** + * @var IRequest&MockObject + */ + private $request; + + /** + * @var DashboardTreeService&MockObject + */ + private $treeService; + + protected function setUp(): void + { + $this->request = $this->createMock(originalClassName: IRequest::class); + $this->treeService = $this->createMock(originalClassName: DashboardTreeService::class); + }//end setUp() + + /** + * Build the controller with the given user ID (or null for anonymous). + */ + private function makeController(?string $userId): DashboardApiController + { + return new DashboardApiController( + request: $this->request, + dashboardService: $this->createMock(originalClassName: DashboardService::class), + permissionService: $this->createMock(originalClassName: PermissionService::class), + treeService: $this->treeService, + versionService: $this->createMock(originalClassName: DashboardVersionService::class), + analyticsService: $this->createMock(originalClassName: AnalyticsService::class), + logger: $this->createMock(originalClassName: LoggerInterface::class), + userId: $userId, + ); + }//end makeController() + + /** + * Anonymous calls MUST return 401 — the endpoint is `NoAdminRequired` + * but still requires a session. + * + * @return void + */ + public function testReturnsUnauthorizedForAnonymousCaller(): void + { + $controller = $this->makeController(userId: null); + + $this->treeService->expects(matcher: $this->never()) + ->method(constraint: 'computePath'); + + $response = $controller->computePath(uuid: 'abc'); + + $this->assertSame( + expected: Http::STATUS_UNAUTHORIZED, + actual: $response->getStatus() + ); + }//end testReturnsUnauthorizedForAnonymousCaller() + + /** + * Empty uuid path argument MUST return 400 with the + * `missing_uuid` error code. + * + * @return void + */ + public function testReturnsBadRequestWhenUuidIsEmpty(): void + { + $controller = $this->makeController(userId: 'alice'); + + $this->treeService->expects(matcher: $this->never()) + ->method(constraint: 'computePath'); + + $response = $controller->computePath(uuid: ''); + $data = $response->getData(); + + $this->assertSame( + expected: Http::STATUS_BAD_REQUEST, + actual: $response->getStatus() + ); + $this->assertSame(expected: 'missing_uuid', actual: $data['error']); + }//end testReturnsBadRequestWhenUuidIsEmpty() + + /** + * Happy path — tree service returns a path, controller surfaces it + * inside the standard `{path: ...}` envelope. + * + * @return void + */ + public function testReturnsPathFromTreeService(): void + { + $controller = $this->makeController(userId: 'alice'); + + $this->treeService->expects(matcher: $this->once()) + ->method(constraint: 'computePath') + ->with('abc-123') + ->willReturn(value: '/finance/q1'); + + $response = $controller->computePath(uuid: 'abc-123'); + $data = $response->getData(); + + $this->assertSame(expected: Http::STATUS_OK, actual: $response->getStatus()); + $this->assertSame(expected: '/finance/q1', actual: $data['path']); + }//end testReturnsPathFromTreeService() + + /** + * Empty path from the tree service is a valid response — dashboards + * with NULL slugs are unaddressable but legal. The endpoint MUST + * return 200 with an empty path so the frontend can distinguish + * "no URL update needed" from "lookup failed". + * + * @return void + */ + public function testReturnsEmptyPathAsValidResponse(): void + { + $controller = $this->makeController(userId: 'alice'); + + $this->treeService->expects(matcher: $this->once()) + ->method(constraint: 'computePath') + ->with('no-slug-uuid') + ->willReturn(value: ''); + + $response = $controller->computePath(uuid: 'no-slug-uuid'); + $data = $response->getData(); + + $this->assertSame(expected: Http::STATUS_OK, actual: $response->getStatus()); + $this->assertSame(expected: '', actual: $data['path']); + }//end testReturnsEmptyPathAsValidResponse() +}//end class diff --git a/tests/integration/local.env.json b/tests/integration/local.env.json index 0c3287f6..6f2deae3 100644 --- a/tests/integration/local.env.json +++ b/tests/integration/local.env.json @@ -9,6 +9,7 @@ { "key": "memberPassword", "value": "regular", "type": "secret", "enabled": true }, { "key": "fixtureDashboardId", "value": "", "type": "default", "enabled": true }, { "key": "fixtureDashboardUuid", "value": "", "type": "default", "enabled": true }, + { "key": "fixtureDashboardSlug", "value": "", "type": "default", "enabled": true }, { "key": "fixtureTileId", "value": "", "type": "default", "enabled": true }, { "key": "fixturePlacementId", "value": "", "type": "default", "enabled": true }, { "key": "fixtureCommentId", "value": "", "type": "default", "enabled": true }, diff --git a/tests/integration/mydash.postman_collection.json b/tests/integration/mydash.postman_collection.json index 08c45a40..36ce9457 100644 --- a/tests/integration/mydash.postman_collection.json +++ b/tests/integration/mydash.postman_collection.json @@ -122,6 +122,59 @@ } } ] + }, + { + "name": "GET /apps/mydash/{slug} (deep-link, fallback when no fixture slug)", + "request": { + "method": "GET", + "header": [ + { "key": "Accept", "value": "text/html" } + ], + "url": { "raw": "{{baseUrl}}/index.php/apps/mydash/newman-deeplink-probe", "host": ["{{baseUrl}}"], "path": ["index.php", "apps", "mydash", "newman-deeplink-probe"] } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Pins the silent-fallback contract: an unknown slug-chain MUST", + "// still render the workspace shell (200 HTML) — bookmarks of", + "// renamed/deleted dashboards land somewhere instead of breaking.", + "pm.test('200 OK (silent fallback for unknown slug)', () => pm.response.to.have.status(200));", + "pm.test('returns HTML (page shell)', () => {", + " pm.expect(pm.response.headers.get('Content-Type') || '').to.match(/text\\/html|application\\/json/);", + "});" + ] + } + } + ] + }, + { + "name": "GET /apps/mydash/api/health (regression: catch-all does not shadow API)", + "request": { + "method": "GET", + "header": [], + "url": { "raw": "{{baseUrl}}/index.php/apps/mydash/api/health", "host": ["{{baseUrl}}"], "path": ["index.php", "apps", "mydash", "api", "health"] } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// REGRESSION: the deep-link catch-all `/{deepLink}` MUST NOT", + "// swallow `/api/...` requests — the negative-lookahead in", + "// `appinfo/routes.php` is the only thing keeping API endpoints", + "// reachable. A 200 here proves the route-ordering + regex still hold.", + "pm.test('200 OK (API still reachable past the catch-all)', () => pm.response.to.have.status(200));", + "pm.test('returns JSON envelope (not HTML page shell)', () => {", + " pm.expect(pm.response.headers.get('Content-Type') || '').to.match(/application\\/json/);", + "});" + ] + } + } + ] } ] }, @@ -180,6 +233,11 @@ " pm.expect(json.dashboard).to.have.property('uuid');", " pm.environment.set('fixtureDashboardId', String(json.dashboard.id));", " pm.environment.set('fixtureDashboardUuid', String(json.dashboard.uuid));", + " // Capture the auto-generated slug so the deep-link tests", + " // below can hit `/apps/mydash/{slug}` without guessing.", + " if (json.dashboard.slug) {", + " pm.environment.set('fixtureDashboardSlug', String(json.dashboard.slug));", + " }", "});" ] } @@ -220,6 +278,105 @@ } ] }, + { + "name": "Dashboards - Deep-link", + "item": [ + { + "name": "GET /api/dashboards/{uuid}/path (canonical slug-chain lookup)", + "request": { + "method": "GET", + "url": { "raw": "{{baseUrl}}/index.php/apps/mydash/api/dashboards/{{fixtureDashboardUuid}}/path", "host": ["{{baseUrl}}"], "path": ["index.php", "apps", "mydash", "api", "dashboards", "{{fixtureDashboardUuid}}", "path"] } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('200 OK', () => pm.response.to.have.status(200));", + "pm.test('envelope carries a `path` string', () => {", + " const json = pm.response.json();", + " pm.expect(json).to.have.property('path').that.is.a('string');", + "});", + "pm.test('path matches the captured fixture slug when one was generated', () => {", + " const fixtureSlug = pm.environment.get('fixtureDashboardSlug');", + " if (fixtureSlug) {", + " const json = pm.response.json();", + " pm.expect(json.path).to.match(new RegExp(`/${fixtureSlug}$`));", + " }", + "});" + ] + } + } + ] + }, + { + "name": "GET /api/dashboards/missing-uuid/path (404 / 400 envelope)", + "request": { + "method": "GET", + "url": { "raw": "{{baseUrl}}/index.php/apps/mydash/api/dashboards/00000000-0000-0000-0000-000000000000/path", "host": ["{{baseUrl}}"], "path": ["index.php", "apps", "mydash", "api", "dashboards", "00000000-0000-0000-0000-000000000000", "path"] } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Unknown UUIDs MUST come back as 200 with an empty path —", + "// the empty string is a documented `no addressable URL` signal", + "// the frontend distinguishes from genuine errors. The endpoint", + "// never 404s on unknown uuids.", + "pm.test('200 OK', () => pm.response.to.have.status(200));", + "pm.test('empty path is a valid response', () => {", + " const json = pm.response.json();", + " pm.expect(json).to.have.property('path').that.equals('');", + "});" + ] + } + } + ] + }, + { + "name": "GET /apps/mydash/{slug} (deep-link page render with known fixture slug)", + "request": { + "method": "GET", + "header": [ + { "key": "Accept", "value": "text/html" } + ], + "url": { "raw": "{{baseUrl}}/index.php/apps/mydash/{{fixtureDashboardSlug}}", "host": ["{{baseUrl}}"], "path": ["index.php", "apps", "mydash", "{{fixtureDashboardSlug}}"] } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "// Skip when the fixture didn't yield a slug (older backend", + "// path that doesn't auto-derive). The previous health-section", + "// test already covers the unknown-slug fallback contract, so", + "// gracefully no-oping here keeps the suite green for those runs.", + "if (!pm.environment.get('fixtureDashboardSlug')) {", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('200 OK', () => pm.response.to.have.status(200));", + "pm.test('returns HTML (page shell, deep-link path matched the fixture)', () => {", + " pm.expect(pm.response.headers.get('Content-Type') || '').to.match(/text\\/html|application\\/json/);", + "});" + ] + } + } + ] + } + ] + }, { "name": "Dashboards - Personal scope", "item": [ From d86b9b3a883f2db257be1be06da85261c5a4545d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 5 May 2026 22:54:19 +0200 Subject: [PATCH 2/2] ci: retrigger after branch rename