diff --git a/core/Support/helpers/app.php b/core/Support/helpers/app.php index 5370d73..5d4aba1 100644 --- a/core/Support/helpers/app.php +++ b/core/Support/helpers/app.php @@ -143,6 +143,42 @@ function lurl(string $path = ''): string return localeBase() . ltrim($path, '/'); } +/** + * Locale-aware URL for an endpoint, resolved through the module route map. + * + * Same as lurl(), but if the given path is a registered module route + * source path it returns a URL pointing at the route TARGET path instead. + * + * Why this exists: MintyPHP's Router::applyRoutes() compares the full + * request URI (including `?query`) against registered source paths, so a + * browser navigation to `/admin/foo?x=1` misses the rewrite for a module + * route `admin/foo → some/other/foo` and falls back to file-based routing + * — which cannot find `admin/foo`. Using the target path up-front sidesteps + * the rewrite entirely and works regardless of query string. + * + * Use endpointUrl() for URLs that will carry query params (data endpoints, + * export downloads, AJAX calls). Plain lurl() is still fine for nav links + * without a query string. + */ +function endpointUrl(string $path = ''): string +{ + $path = ltrim($path, '/'); + try { + $registry = app(\MintyPHP\App\Module\ModuleRegistry::class); + } catch (\Throwable) { + return lurl($path); + } + foreach ($registry->getRoutes() as $route) { + if (($route['path'] ?? '') === $path) { + $target = (string) ($route['target'] ?? ''); + if ($target !== '') { + return lurl($target); + } + } + } + return lurl($path); +} + /** * App title from settings with APP_NAME fallback. */ diff --git a/modules/audit/pages/audit/system-audit/index(default).phtml b/modules/audit/pages/audit/system-audit/index(default).phtml index f25ba73..1485d70 100644 --- a/modules/audit/pages/audit/system-audit/index(default).phtml +++ b/modules/audit/pages/audit/system-audit/index(default).phtml @@ -46,11 +46,7 @@ $pageConfig = [ 'filterSchema' => $clientFilterSchema, 'filterChipMeta' => $filterChipMeta, 'gridLang' => $gridLang, - // Uses the page target path, not the registered route path: the Router's - // applyRoutes() rewrite does not strip query strings before matching, so - // "admin/system-audit/export?format=csv" would miss the rewrite and 404. - // Matches the convention already used for `dataUrl: 'audit/system-audit/data'`. - 'exportUrl' => lurl('audit/system-audit/export'), + 'exportUrl' => endpointUrl('admin/system-audit/export'), 'labels' => [ 'created' => t('Created'), 'status' => t('Status'), diff --git a/modules/helpdesk/pages/helpdesk/domains/index(default).phtml b/modules/helpdesk/pages/helpdesk/domains/index(default).phtml index 9f0b57c..cadf780 100644 --- a/modules/helpdesk/pages/helpdesk/domains/index(default).phtml +++ b/modules/helpdesk/pages/helpdesk/domains/index(default).phtml @@ -75,7 +75,7 @@ require templatePath('partials/app-list-filters.phtml'); 'dataUrl' => lurl('helpdesk/domains-data'), 'domainBaseUrl' => lurl('helpdesk/domain/'), 'securityLevelDataUrl' => lurl('helpdesk/domains/security-level-data'), - 'exportUrl' => lurl('helpdesk/domains/export'), + 'exportUrl' => endpointUrl('helpdesk/domains/export'), 'gridLang' => gridLang(), 'gridSearch' => $searchConfig, 'filterSchema' => $clientFilterSchema, diff --git a/pages/admin/users/index(default).phtml b/pages/admin/users/index(default).phtml index 30af252..a50b308 100644 --- a/pages/admin/users/index(default).phtml +++ b/pages/admin/users/index(default).phtml @@ -110,7 +110,7 @@ $pageConfig = [ 'gridSearch' => $searchConfig, 'filterSchema' => $clientFilterSchema, 'filterChipMeta' => $filterChipMeta, - 'exportUrl' => lurl('admin/users/export'), + 'exportUrl' => endpointUrl('admin/users/export'), 'currentUserUuid' => (string) $currentUserUuid, 'canUpdateUsers' => (bool) $canUpdateUsers, 'canUpdateSelf' => (bool) $canUpdateSelf, diff --git a/tests/Support/Helpers/EndpointUrlTest.php b/tests/Support/Helpers/EndpointUrlTest.php new file mode 100644 index 0000000..0439cd9 --- /dev/null +++ b/tests/Support/Helpers/EndpointUrlTest.php @@ -0,0 +1,80 @@ +restoreAppContainer(); + } + + public function testResolvesSourcePathToTarget(): void + { + $this->installRegistryWithRoutes([ + ['path' => 'admin/system-audit/export', 'target' => 'audit/system-audit/export', 'module_id' => 'audit'], + ]); + + $this->assertStringEndsWith('audit/system-audit/export', endpointUrl('admin/system-audit/export')); + } + + public function testIdempotentWhenPathEqualsTarget(): void + { + $this->installRegistryWithRoutes([ + ['path' => 'helpdesk/domains/export', 'target' => 'helpdesk/domains/export', 'module_id' => 'helpdesk'], + ]); + + $this->assertStringEndsWith('helpdesk/domains/export', endpointUrl('helpdesk/domains/export')); + } + + public function testFallsThroughToLurlForUnregisteredPath(): void + { + $this->installRegistryWithRoutes([]); + + $this->assertSame(lurl('admin/users/export'), endpointUrl('admin/users/export')); + } + + public function testIgnoresLeadingSlash(): void + { + $this->installRegistryWithRoutes([ + ['path' => 'admin/system-audit/export', 'target' => 'audit/system-audit/export', 'module_id' => 'audit'], + ]); + + $this->assertSame( + endpointUrl('admin/system-audit/export'), + endpointUrl('/admin/system-audit/export') + ); + } + + public function testDegradesGracefullyWhenRegistryMissing(): void + { + $this->pushAppContainer(new AppContainer()); + + // No ModuleRegistry registered — endpointUrl() must fall back, not throw. + $this->assertSame(lurl('some/path'), endpointUrl('some/path')); + } + + /** + * @param list $routes + */ + private function installRegistryWithRoutes(array $routes): void + { + // ModuleRegistry is final and has no public constructor — build via + // reflection and write the merged-routes field directly. Only used + // by endpointUrl() via getRoutes(), so that is all we need to stage. + $registry = (new \ReflectionClass(ModuleRegistry::class))->newInstanceWithoutConstructor(); + $routesProp = (new \ReflectionClass(ModuleRegistry::class))->getProperty('mergedRoutes'); + $routesProp->setValue($registry, $routes); + + $container = new AppContainer(); + $container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $registry); + $this->pushAppContainer($container); + } +}