From b0057ec0a299dc1f07e942466f9707e608fb8b9a Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Tue, 29 Sep 2026 16:49:28 -0500 Subject: [PATCH 1/2] feat!: own the admin namespace base and resolve it once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin base was a core constant written into config by this component and read back by everyone: `admin_route_segment` and `admin_route_name_prefix` were two values for one namespace, and a component that reached for the module half instead of the composed pair produced names no route answers. The base now lives here, where it belongs: ADMIN_NAME with one override key (admin_name), resolved by getAdminName(). Components that publish admin routes require this package already, so they consult this value instead of restating it. This component's own routes are that namespace itself — getAdminSegment() `admin` and getAdminNamePrefix() `admin.` — so there is no double prefix to special-case. Route names lose the `webware.` vendor segment in the process: the dashboard is `admin.dashboard.read` on `/admin`. BREAKING CHANGE: admin_route_segment/admin_route_name_prefix are replaced by admin_name; getAdminRouteSegment()/getAdminRouteNamePrefix() are replaced by getAdminSegment()/getAdminNamePrefix(); COMPONENT_NAME is declared here. Signed-off-by: Joey Smith --- src/ConfigProvider.php | 10 ++- src/Container/Configuration.php | 64 +++++++++++++++++++ src/Container/RouteProviderFactory.php | 11 ++-- src/View/Helper/AdminUrlFactory.php | 4 +- .../integration/TestAssets/ExpectedConfig.php | 11 ++-- 5 files changed, 82 insertions(+), 18 deletions(-) diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index 018a442..fc92711 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -21,8 +21,7 @@ * allow: array> * } * @type DefaultConfig = array{ - * admin_route_segment: string, - * admin_route_name_prefix: string + * admin_name: string * } * @type Dependencies = array{ * factories: array @@ -66,11 +65,11 @@ public function getAclConfig(): array 'Administrator' => ['User'], ], 'resources' => [ - Container\Configuration::ADMIN_ROUTE_NAME_PREFIX_VALUE . 'dashboard.read', + Container\Configuration::ADMIN_NAME . '.dashboard.read', ], 'allow' => [ 'Administrator' => [ - Container\Configuration::ADMIN_ROUTE_NAME_PREFIX_VALUE . 'dashboard.read', + Container\Configuration::ADMIN_NAME . '.dashboard.read', ], ], ]; @@ -80,8 +79,7 @@ public function getAclConfig(): array public function getDefaultConfig(): array { return [ - Container\Configuration::ADMIN_ROUTE_SEGMENT_KEY => Container\Configuration::ADMIN_ROUTE_SEGMENT_VALUE, - Container\Configuration::ADMIN_ROUTE_NAME_PREFIX_KEY => Container\Configuration::ADMIN_ROUTE_NAME_PREFIX_VALUE, + Container\Configuration::ADMIN_NAME_KEY => Container\Configuration::ADMIN_NAME, ]; } diff --git a/src/Container/Configuration.php b/src/Container/Configuration.php index 38f0456..f0596dd 100644 --- a/src/Container/Configuration.php +++ b/src/Container/Configuration.php @@ -4,10 +4,74 @@ namespace Webware\Admin\Container; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; use Webware\Admin\AdminInterface; use Webware\Core\Configuration as Config; +use function is_string; + +/** + * Route naming for the admin namespace. + * + * This component owns the admin base every other module nests under. It is resolved here + * once — ADMIN_NAME by default, overridable through ADMIN_NAME_KEY — and consulted by any + * component that publishes admin routes, so the fleet cannot disagree about the base. + * + * This component's own routes are that namespace itself: segment `admin`, name prefix + * `admin.`. + */ final readonly class Configuration extends Config { public const string CONFIG_KEY = AdminInterface::class; + + /** This component's own name. */ + public const string COMPONENT_NAME = 'admin'; + + /** Default admin namespace base. */ + public const string ADMIN_NAME = 'admin'; + + /** Config key holding an override for ADMIN_NAME. */ + public const string ADMIN_NAME_KEY = 'admin_name'; + + /** + * The admin namespace base, e.g. `admin`. Consult this — never a local constant — + * when composing an admin route name or segment, so an application that relocates + * the namespace relocates every module's admin routes with it. + * + * @throws ContainerExceptionInterface + */ + public static function getAdminName(ContainerInterface $container, string $callingFactory): string + { + $config = self::getConfig($container, $callingFactory); + + /** @var mixed $value */ + $value = $config[self::ADMIN_NAME_KEY] ?? null; + + if (! is_string($value) || '' === $value) { + return self::ADMIN_NAME; + } + + return $value; + } + + /** + * Prefix shared by this component's own route names, e.g. `admin.`. + * + * @throws ContainerExceptionInterface + */ + public static function getAdminNamePrefix(ContainerInterface $container, string $callingFactory): string + { + return self::getAdminName($container, $callingFactory) . '.'; + } + + /** + * URI segment this component's own routes are mounted on, e.g. `admin`. + * + * @throws ContainerExceptionInterface + */ + public static function getAdminSegment(ContainerInterface $container, string $callingFactory): string + { + return self::getAdminName($container, $callingFactory); + } } diff --git a/src/Container/RouteProviderFactory.php b/src/Container/RouteProviderFactory.php index 1673ca6..d008233 100644 --- a/src/Container/RouteProviderFactory.php +++ b/src/Container/RouteProviderFactory.php @@ -8,20 +8,23 @@ use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; use Webware\Admin\RouteProvider; +use Webware\Core\Exception; final readonly class RouteProviderFactory { /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface + * @throws Exception\ExceptionInterface */ public function __invoke(ContainerInterface $container): RouteProvider { - $adminRouteSegment = Configuration::getAdminRouteSegment($container, self::class); - $adminRouteNamePrefix = Configuration::getAdminRouteNamePrefix($container, self::class); + $adminRouteSegment = Configuration::getAdminSegment($container, self::class); + $adminRouteNamePrefix = Configuration::getAdminNamePrefix($container, self::class); - // The admin route segment is the base segment for all admin routes, e.g. 'admin'. - // The admin route name prefix is the base prefix for all admin route names, e.g. 'admin.'. + // This component's own routes are the admin namespace itself: segment 'admin', + // name prefix 'admin.'. Both resolve through getAdminName() so an application that + // relocates the namespace moves these routes with it. return new RouteProvider( $adminRouteSegment, $adminRouteNamePrefix, diff --git a/src/View/Helper/AdminUrlFactory.php b/src/View/Helper/AdminUrlFactory.php index f42325e..accea49 100644 --- a/src/View/Helper/AdminUrlFactory.php +++ b/src/View/Helper/AdminUrlFactory.php @@ -9,18 +9,20 @@ use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; use Webware\Admin\Container\Configuration; +use Webware\Core\Exception; final readonly class AdminUrlFactory { /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface + * @throws Exception\ExceptionInterface */ public function __invoke(ContainerInterface $container): AdminUrl { return new AdminUrl( urlHelper : $container->get(UrlHelper::class), - routeNamePrefix: Configuration::getAdminRouteNamePrefix($container, self::class), + routeNamePrefix: Configuration::getAdminNamePrefix($container, self::class), ); } } diff --git a/test/integration/TestAssets/ExpectedConfig.php b/test/integration/TestAssets/ExpectedConfig.php index b9d6eb5..e8f77d7 100644 --- a/test/integration/TestAssets/ExpectedConfig.php +++ b/test/integration/TestAssets/ExpectedConfig.php @@ -21,7 +21,7 @@ final class ExpectedConfig { public static function getDashboardResource(): string { - return Configuration::ADMIN_ROUTE_NAME_PREFIX_VALUE . 'dashboard.read'; + return Configuration::ADMIN_NAME . '.dashboard.read'; } /** @@ -74,8 +74,7 @@ public static function getExpectedAclConfig(): array * allow: array> * }, * Webware\Admin\AdminInterface: array{ - * admin_route_segment: string, - * admin_route_name_prefix: string + * admin_name: string * } * } */ @@ -93,15 +92,13 @@ public static function getExpectedConfig(): array /** * @return array{ - * admin_route_segment: string, - * admin_route_name_prefix: string + * admin_name: string * } */ public static function getExpectedDefaultConfig(): array { return [ - Configuration::ADMIN_ROUTE_SEGMENT_KEY => Configuration::ADMIN_ROUTE_SEGMENT_VALUE, - Configuration::ADMIN_ROUTE_NAME_PREFIX_KEY => Configuration::ADMIN_ROUTE_NAME_PREFIX_VALUE, + Configuration::ADMIN_NAME_KEY => Configuration::ADMIN_NAME, ]; } From 7af55c71d6830020294383c4e8045bb4c82e00f2 Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Tue, 29 Sep 2026 17:07:05 -0500 Subject: [PATCH 2/2] test: cover the admin name resolution and its two consumers Codecov's patch gate flagged 13 uncovered lines in this branch: the three new accessors had no tests, and neither factory had one at all. The accessors are covered for the default, a configured override, and the fallback for a value that is not a usable string. The factories are asserted behaviourally rather than through reflection: the route factory registers /webware.admin's successor at the resolved path and name, and the URL factory passes the resolved prefix to the helper, so a mis-composed namespace fails the test instead of hiding in private state. Signed-off-by: Joey Smith --- test/unit/Container/ConfigurationTest.php | 78 +++++++++++++++++ .../Container/RouteProviderFactoryTest.php | 87 +++++++++++++++++++ test/unit/View/Helper/AdminUrlFactoryTest.php | 67 ++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 test/unit/Container/ConfigurationTest.php create mode 100644 test/unit/Container/RouteProviderFactoryTest.php create mode 100644 test/unit/View/Helper/AdminUrlFactoryTest.php diff --git a/test/unit/Container/ConfigurationTest.php b/test/unit/Container/ConfigurationTest.php new file mode 100644 index 0000000..d89526e --- /dev/null +++ b/test/unit/Container/ConfigurationTest.php @@ -0,0 +1,78 @@ +container([Configuration::ADMIN_NAME_KEY => 'control-panel']); + + self::assertSame('control-panel', Configuration::getAdminName($container, self::class)); + self::assertSame('control-panel.', Configuration::getAdminNamePrefix($container, self::class)); + self::assertSame('control-panel', Configuration::getAdminSegment($container, self::class)); + } + + #[Test] + public function anUnusableConfiguredValueFallsBackToTheDefault(): void + { + foreach ([null, '', 42, ['admin']] as $value) { + $container = $this->container([Configuration::ADMIN_NAME_KEY => $value]); + + self::assertSame( + Configuration::ADMIN_NAME, + Configuration::getAdminName($container, self::class), + ); + } + } + + #[Test] + public function itThrowsWhenTheConfigServiceIsMissing(): void + { + $container = $this->createStub(ContainerInterface::class); + $container->method('has')->willReturn(false); + + $this->expectException(ContainerException::class); + + Configuration::getAdminName($container, self::class); + } + + #[Test] + public function theAdminNameDefaultsToAdmin(): void + { + self::assertSame('admin', Configuration::getAdminName($this->container([]), self::class)); + } + + #[Test] + public function theDefaultNamespaceIsTheAdminPrefixAndSegment(): void + { + $container = $this->container([]); + + self::assertSame('admin.', Configuration::getAdminNamePrefix($container, self::class)); + self::assertSame('admin', Configuration::getAdminSegment($container, self::class)); + } + + /** + * @param array $config + */ + private function container(array $config): ContainerInterface + { + $container = $this->createStub(ContainerInterface::class); + $container->method('has')->willReturn(true); + $container->method('get')->willReturn([AdminInterface::class => $config]); + + return $container; + } +} diff --git a/test/unit/Container/RouteProviderFactoryTest.php b/test/unit/Container/RouteProviderFactoryTest.php new file mode 100644 index 0000000..09d97d5 --- /dev/null +++ b/test/unit/Container/RouteProviderFactoryTest.php @@ -0,0 +1,87 @@ +registeredRoute($this->container([])), + ); + } + + #[Test] + public function theRegisteredRouteFollowsAConfiguredNamespace(): void + { + self::assertSame( + ['/control-panel', 'control-panel.dashboard.read'], + $this->registeredRoute( + $this->container([Configuration::ADMIN_NAME_KEY => 'control-panel']), + ), + ); + } + + /** + * @param array $config + */ + private function container(array $config): ContainerInterface + { + $container = $this->createStub(ContainerInterface::class); + $container->method('has')->willReturn(true); + $container->method('get')->willReturn([AdminInterface::class => $config]); + + return $container; + } + + /** + * @return array{string, string|null} the path and route name the provider registered + */ + private function registeredRoute(ContainerInterface $container): array + { + /** @var array{string, string|null} $registered */ + $registered = []; + + $collector = $this->createMock(RouteCollectorInterface::class); + $collector->expects($this->once()) + ->method('get') + ->willReturnCallback( + static function ( + string $path, + MiddlewareInterface $middleware, + ?string $name = null, + ) use (&$registered): Route { + $registered = [$path, $name]; + + return new Route($path, $middleware, ['GET'], $name); + }, + ); + + $middlewareFactory = $this->createStub(MiddlewareFactoryInterface::class); + $middlewareFactory->method('prepare') + ->willReturn( + $this->createStub(MiddlewareInterface::class), + ); + + (new RouteProviderFactory())($container)->registerRoutes($collector, $middlewareFactory); + + return $registered; + } +} diff --git a/test/unit/View/Helper/AdminUrlFactoryTest.php b/test/unit/View/Helper/AdminUrlFactoryTest.php new file mode 100644 index 0000000..f229b7f --- /dev/null +++ b/test/unit/View/Helper/AdminUrlFactoryTest.php @@ -0,0 +1,67 @@ +resolveUrl('dashboard.read', [], 'admin.dashboard.read')); + } + + #[Test] + public function thePrefixFollowsAConfiguredNamespace(): void + { + self::assertSame( + '/admin', + $this->resolveUrl( + 'dashboard.read', + [Configuration::ADMIN_NAME_KEY => 'control-panel'], + 'control-panel.dashboard.read', + ), + ); + } + + /** + * @param array $config + */ + private function container(array $config, UrlHelper $urlHelper): ContainerInterface + { + $container = $this->createStub(ContainerInterface::class); + $container->method('has')->willReturn(true); + $container->method('get') + ->willReturnMap([ + [UrlHelper::class, $urlHelper], + ['config', [AdminInterface::class => $config]], + ]); + + return $container; + } + + /** + * @param array $config + */ + private function resolveUrl(string $routeName, array $config, string $expectedRoute): string + { + $urlHelper = $this->createMock(UrlHelper::class); + $urlHelper->expects($this->once()) + ->method('__invoke') + ->with($expectedRoute) + ->willReturn('/admin'); + + return (new AdminUrlFactory())($this->container($config, $urlHelper))($routeName); + } +}