feat: implement milestone 3 - comprehensive test coverage

This commit is contained in:
Funky Waddle 2026-02-14 17:27:15 -06:00
parent ab7719a39f
commit 7152e2b3e7
7 changed files with 615 additions and 55 deletions

View file

@ -22,7 +22,7 @@ class Config implements ArrayAccess, IteratorAggregate
* @param array $options Configuration array containing routing settings * @param array $options Configuration array containing routing settings
*/ */
public function __construct( public function __construct(
private readonly array $options private array $options
) {} ) {}
/** /**

View file

@ -9,6 +9,8 @@ namespace Atlas\Router;
*/ */
class RouteGroup class RouteGroup
{ {
use PathHelper;
/** /**
* Constructs a new RouteGroup instance. * Constructs a new RouteGroup instance.
* *
@ -34,74 +36,131 @@ class RouteGroup
return $self; return $self;
} }
/** public function get(string $path, mixed $handler, string|null $name = null): RouteDefinition
* Registers a GET route with group prefix.
*
* @param string $path URI path
* @param string|callable $handler Route handler
* @param string|null $name Optional route name
* @return self Fluent interface
*/
public function get(string $path, string|callable $handler, string|null $name = null): self
{ {
$fullPath = $this->buildFullPath($path); $fullPath = $this->buildFullPath($path);
return $this->router ? $this->router->get($fullPath, $handler, $name) : $this; $middleware = $this->options['middleware'] ?? [];
$validation = $this->options['validation'] ?? [];
$defaults = $this->options['defaults'] ?? [];
if ($this->router) {
return $this->router->registerCustomRoute('GET', $fullPath, $handler, $name, $middleware, $validation, $defaults);
}
return new RouteDefinition('GET', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults);
} }
/** public function post(string $path, mixed $handler, string|null $name = null): RouteDefinition
* Registers a POST route with group prefix.
*
* @param string $path URI path
* @param string|callable $handler Route handler
* @param string|null $name Optional route name
* @return self Fluent interface
*/
public function post(string $path, string|callable $handler, string|null $name = null): self
{ {
$fullPath = $this->buildFullPath($path); $fullPath = $this->buildFullPath($path);
return $this->router ? $this->router->post($fullPath, $handler, $name) : $this; $middleware = $this->options['middleware'] ?? [];
$validation = $this->options['validation'] ?? [];
$defaults = $this->options['defaults'] ?? [];
if ($this->router) {
return $this->router->registerCustomRoute('POST', $fullPath, $handler, $name, $middleware, $validation, $defaults);
}
return new RouteDefinition('POST', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults);
} }
/** public function put(string $path, mixed $handler, string|null $name = null): RouteDefinition
* Registers a PUT route with group prefix.
*
* @param string $path URI path
* @param string|callable $handler Route handler
* @param string|null $name Optional route name
* @return self Fluent interface
*/
public function put(string $path, string|callable $handler, string|null $name = null): self
{ {
$fullPath = $this->buildFullPath($path); $fullPath = $this->buildFullPath($path);
return $this->router ? $this->router->put($fullPath, $handler, $name) : $this; $middleware = $this->options['middleware'] ?? [];
$validation = $this->options['validation'] ?? [];
$defaults = $this->options['defaults'] ?? [];
if ($this->router) {
return $this->router->registerCustomRoute('PUT', $fullPath, $handler, $name, $middleware, $validation, $defaults);
}
return new RouteDefinition('PUT', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults);
} }
/** public function patch(string $path, mixed $handler, string|null $name = null): RouteDefinition
* Registers a PATCH route with group prefix.
*
* @param string $path URI path
* @param string|callable $handler Route handler
* @param string|null $name Optional route name
* @return self Fluent interface
*/
public function patch(string $path, string|callable $handler, string|null $name = null): self
{ {
$fullPath = $this->buildFullPath($path); $fullPath = $this->buildFullPath($path);
return $this->router ? $this->router->patch($fullPath, $handler, $name) : $this; $middleware = $this->options['middleware'] ?? [];
$validation = $this->options['validation'] ?? [];
$defaults = $this->options['defaults'] ?? [];
if ($this->router) {
return $this->router->registerCustomRoute('PATCH', $fullPath, $handler, $name, $middleware, $validation, $defaults);
}
return new RouteDefinition('PATCH', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults);
} }
/** public function delete(string $path, mixed $handler, string|null $name = null): RouteDefinition
* Registers a DELETE route with group prefix.
*
* @param string $path URI path
* @param string|callable $handler Route handler
* @param string|null $name Optional route name
* @return self Fluent interface
*/
public function delete(string $path, string|callable $handler, string|null $name = null): self
{ {
$fullPath = $this->buildFullPath($path); $fullPath = $this->buildFullPath($path);
return $this->router ? $this->router->delete($fullPath, $handler, $name) : $this; $middleware = $this->options['middleware'] ?? [];
$validation = $this->options['validation'] ?? [];
$defaults = $this->options['defaults'] ?? [];
if ($this->router) {
return $this->router->registerCustomRoute('DELETE', $fullPath, $handler, $name, $middleware, $validation, $defaults);
}
return new RouteDefinition('DELETE', $fullPath, $fullPath, $handler, $name, $middleware, $validation, $defaults);
}
public function redirect(string $path, string $destination, int $status = 302): RouteDefinition
{
$fullPath = $this->buildFullPath($path);
$middleware = $this->options['middleware'] ?? [];
$validation = $this->options['validation'] ?? [];
$defaults = $this->options['defaults'] ?? [];
if ($this->router) {
return $this->router->registerCustomRoute('REDIRECT', $fullPath, $destination, null, $middleware, $validation, $defaults)->attr('status', $status);
}
return (new RouteDefinition('REDIRECT', $fullPath, $fullPath, $destination, null, $middleware, $validation, $defaults))->attr('status', $status);
}
public function fallback(mixed $handler): self
{
$this->options['fallback'] = $handler;
$prefix = $this->options['prefix'] ?? '/';
$middleware = $this->options['middleware'] ?? [];
if ($this->router) {
$this->router->registerCustomRoute('FALLBACK', $this->joinPaths($prefix, '/_fallback'), $handler, null, $middleware)
->attr('_fallback', $handler)
->attr('_fallback_prefix', $this->normalizePath($prefix));
}
return $this;
}
public function registerCustomRoute(string $method, string $path, mixed $handler, string|null $name = null, array $middleware = [], array $validation = [], array $defaults = []): RouteDefinition
{
$fullPath = $this->buildFullPath($path);
$mergedMiddleware = array_merge($this->options['middleware'] ?? [], $middleware);
$route = null;
if ($this->router) {
$route = $this->router->registerCustomRoute($method, $fullPath, $handler, $name, $mergedMiddleware);
} else {
$route = new RouteDefinition($method, $fullPath, $fullPath, $handler, $name, $mergedMiddleware);
}
// Apply group-level validation and defaults
$route->valid($this->options['validation'] ?? []);
foreach ($this->options['defaults'] ?? [] as $p => $v) {
$route->default($p, $v);
}
// Apply route-level validation and defaults
$route->valid($validation);
foreach ($defaults as $p => $v) {
$route->default($p, $v);
}
return $route;
} }
/** /**
@ -112,13 +171,72 @@ class RouteGroup
*/ */
private function buildFullPath(string $path): string private function buildFullPath(string $path): string
{ {
$prefix = $this->options['prefix'] ?? ''; return $this->joinPaths($this->options['prefix'] ?? '', $path);
}
if (empty($prefix)) { public function group(array $options): RouteGroup
return $path; {
$prefix = $this->options['prefix'] ?? '';
$newPrefix = $this->joinPaths($prefix, $options['prefix'] ?? '');
$middleware = $this->options['middleware'] ?? [];
$newMiddleware = array_merge($middleware, $options['middleware'] ?? []);
$validation = $this->options['validation'] ?? [];
$newValidation = array_merge($validation, $options['validation'] ?? []);
$defaults = $this->options['defaults'] ?? [];
$newDefaults = array_merge($defaults, $options['defaults'] ?? []);
$mergedOptions = array_merge($this->options, $options);
$mergedOptions['prefix'] = $newPrefix;
$mergedOptions['middleware'] = $newMiddleware;
$mergedOptions['validation'] = $newValidation;
$mergedOptions['defaults'] = $newDefaults;
return new RouteGroup($mergedOptions, $this->router);
}
/**
* Sets validation rules for parameters at the group level.
*
* @param array|string $param Parameter name or array of rules
* @param array|string $rules Rules if first param is string
* @return self
*/
public function valid(array|string $param, array|string $rules = []): self
{
if (!isset($this->options['validation'])) {
$this->options['validation'] = [];
} }
return rtrim($prefix, '/') . '/' . ltrim($path, '/'); if (is_array($param)) {
foreach ($param as $p => $r) {
$this->valid($p, $r);
}
} else {
$this->options['validation'][$param] = is_string($rules) ? [$rules] : $rules;
}
return $this;
}
/**
* Sets a default value for a parameter at the group level.
*
* @param string $param
* @param mixed $value
* @return self
*/
public function default(string $param, mixed $value): self
{
if (!isset($this->options['defaults'])) {
$this->options['defaults'] = [];
}
$this->options['defaults'][$param] = $value;
return $this;
} }
/** /**
@ -139,8 +257,33 @@ class RouteGroup
* *
* @return array Group options configuration * @return array Group options configuration
*/ */
public function module(string|array $identifier, string|null $prefix = null): self
{
if ($this->router) {
// We need to pass the group context to the module loading.
// But ModuleLoader uses the router directly.
// If we use $this->router->module(), it won't have the group prefix/middleware.
// We should probably allow ModuleLoader to take a "target" which can be a Router or RouteGroup.
// For now, let's just use the router but we have a problem: inheritance.
// A better way is to make RouteGroup have a way to load modules.
$moduleLoader = new ModuleLoader($this->router->getConfig(), $this);
$moduleLoader->load($identifier, $prefix);
}
return $this;
}
public function getOptions(): array public function getOptions(): array
{ {
return $this->options; return $this->options;
} }
/**
* @internal
*/
public function getConfig(): Config
{
return $this->router->getConfig();
}
} }

View file

@ -0,0 +1,112 @@
<?php
namespace Atlas\Tests\Integration;
use Atlas\Router\Router;
use Atlas\Config\Config;
use Atlas\Exception\MissingConfigurationException;
use PHPUnit\Framework\TestCase;
class ModuleLoadingTest extends TestCase
{
private string $tempDir;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/atlas_test_' . uniqid();
mkdir($this->tempDir);
mkdir($this->tempDir . '/User');
}
protected function tearDown(): void
{
$this->removeDirectory($this->tempDir);
}
private function removeDirectory(string $path): void
{
$files = array_diff(scandir($path), ['.', '..']);
foreach ($files as $file) {
(is_dir("$path/$file")) ? $this->removeDirectory("$path/$file") : unlink("$path/$file");
}
rmdir($path);
}
public function testModuleLoadsRoutesFromFilesystem(): void
{
$routesContent = '<?php return [["method" => "GET", "path" => "/profile", "handler" => "UserHandler", "name" => "user_profile"]];';
file_put_contents($this->tempDir . '/User/routes.php', $routesContent);
$config = new Config([
'modules_path' => [$this->tempDir],
'routes_file' => 'routes.php'
]);
$router = new Router($config);
$router->module('User');
$routes = iterator_to_array($router->getRoutes());
$this->assertCount(1, $routes);
$this->assertSame('/profile', $routes[0]->getPath());
$this->assertSame('user_profile', $routes[0]->getName());
}
public function testModuleThrowsExceptionWhenModulesPathIsMissing(): void
{
$config = new Config([]);
$router = new Router($config);
$this->expectException(MissingConfigurationException::class);
$router->module('User');
}
public function testModuleSkipsWhenRoutesFileDoesNotExist(): void
{
$config = new Config([
'modules_path' => [$this->tempDir]
]);
$router = new Router($config);
$router->module('NonExistent');
$this->assertCount(0, $router->getRoutes());
}
public function testModuleWithMultipleSearchPaths(): void
{
$secondPath = sys_get_temp_dir() . '/atlas_test_2_' . uniqid();
mkdir($secondPath);
mkdir($secondPath . '/Shared');
$routesContent = '<?php return [["method" => "GET", "path" => "/shared", "handler" => "SharedHandler"]];';
file_put_contents($secondPath . '/Shared/routes.php', $routesContent);
$config = new Config([
'modules_path' => [$this->tempDir, $secondPath]
]);
$router = new Router($config);
$router->module('Shared');
$routes = iterator_to_array($router->getRoutes());
$this->assertCount(1, $routes);
$this->assertSame('/shared', $routes[0]->getPath());
$this->removeDirectory($secondPath);
}
public function testModuleLoadsMultipleRoutes(): void
{
$routesContent = '<?php return [
["method" => "GET", "path" => "/u1", "handler" => "h1"],
["method" => "POST", "path" => "/u2", "handler" => "h2"]
];';
file_put_contents($this->tempDir . '/User/routes.php', $routesContent);
$config = new Config(['modules_path' => $this->tempDir]);
$router = new Router($config);
$router->module('User');
$this->assertCount(2, $router->getRoutes());
}
}

103
tests/Unit/ConfigTest.php Normal file
View file

@ -0,0 +1,103 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Config\Config;
use PHPUnit\Framework\TestCase;
class ConfigTest extends TestCase
{
public function testGetReturnsValueOrPlaceholder(): void
{
$config = new Config(['key' => 'value']);
$this->assertSame('value', $config->get('key'));
$this->assertSame('default', $config->get('non_existent', 'default'));
$this->assertNull($config->get('non_existent'));
}
public function testHasChecksExistence(): void
{
$config = new Config(['key' => 'value']);
$this->assertTrue($config->has('key'));
$this->assertFalse($config->has('non_existent'));
}
public function testGetModulesPathNormalizesToArray(): void
{
$config1 = new Config(['modules_path' => '/single/path']);
$this->assertSame(['/single/path'], $config1->getModulesPath());
$config2 = new Config(['modules_path' => ['/path/1', '/path/2']]);
$this->assertSame(['/path/1', '/path/2'], $config2->getModulesPath());
$config3 = new Config([]);
$this->assertNull($config3->getModulesPath());
}
public function testGetModulesPathListAlwaysReturnsArray(): void
{
$config1 = new Config(['modules_path' => '/single/path']);
$this->assertSame(['/single/path'], $config1->getModulesPathList());
$config2 = new Config(['modules_path' => ['/path/1', '/path/2']]);
$this->assertSame(['/path/1', '/path/2'], $config2->getModulesPathList());
$config3 = new Config([]);
$this->assertSame([], $config3->getModulesPathList());
}
public function testGetRoutesFileWithDefault(): void
{
$config1 = new Config(['routes_file' => 'custom.php']);
$this->assertSame('custom.php', $config1->getRoutesFile());
$config2 = new Config([]);
$this->assertSame('routes.php', $config2->getRoutesFile());
}
public function testGetModulesGlob(): void
{
$config1 = new Config(['modules_glob' => 'src/*/routes.php']);
$this->assertSame('src/*/routes.php', $config1->getModulesGlob());
$config2 = new Config([]);
$this->assertNull($config2->getModulesGlob());
}
public function testToArray(): void
{
$options = ['a' => 1, 'b' => 2];
$config = new Config($options);
$this->assertSame($options, $config->toArray());
}
public function testArrayAccess(): void
{
$config = new Config(['key' => 'value']);
$this->assertTrue(isset($config['key']));
$this->assertSame('value', $config['key']);
$config['new'] = 'val';
$this->assertSame('val', $config['new']);
unset($config['key']);
$this->assertFalse(isset($config['key']));
}
public function testIteratorAggregate(): void
{
$options = ['a' => 1, 'b' => 2];
$config = new Config($options);
$result = [];
foreach ($config as $key => $value) {
$result[$key] = $value;
}
$this->assertSame($options, $result);
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Router\Router;
use Atlas\Router\RouteGroup;
use Atlas\Config\Config;
use PHPUnit\Framework\TestCase;
class RouteGroupTest extends TestCase
{
private Router $router;
protected function setUp(): void
{
$config = new Config(['modules_path' => ['/path/to/modules']]);
$this->router = new Router($config);
}
public function testGroupAppliesPrefixToRoutes(): void
{
$group = $this->router->group(['prefix' => '/api']);
$group->get('/users', 'Handler');
$routes = iterator_to_array($this->router->getRoutes());
$this->assertCount(1, $routes);
$this->assertSame('/api/users', $routes[0]->getPath());
}
public function testGroupAppliesPrefixWithLeadingSlashToRoutes(): void
{
$group = $this->router->group(['prefix' => 'api']);
$group->get('users', 'Handler');
$routes = iterator_to_array($this->router->getRoutes());
$this->assertCount(1, $routes);
$this->assertSame('/api/users', $routes[0]->getPath());
}
public function testGroupWithTrailingSlashInPrefix(): void
{
$group = $this->router->group(['prefix' => '/api/']);
$group->get('/users', 'Handler');
$routes = iterator_to_array($this->router->getRoutes());
$this->assertCount(1, $routes);
$this->assertSame('/api/users', $routes[0]->getPath());
}
public function testAllHttpMethodsInGroup(): void
{
$group = $this->router->group(['prefix' => '/api']);
$group->get('/test', 'handler');
$group->post('/test', 'handler');
$group->put('/test', 'handler');
$group->patch('/test', 'handler');
$group->delete('/test', 'handler');
$routes = iterator_to_array($this->router->getRoutes());
$this->assertCount(5, $routes);
foreach ($routes as $route) {
$this->assertSame('/api/test', $route->getPath());
}
}
public function testGroupReturnsSameInstanceForChaining(): void
{
$group = $this->router->group(['prefix' => '/api']);
$group->get('/users', 'Handler');
$group->post('/users', 'Handler');
$routes = iterator_to_array($this->router->getRoutes());
$this->assertCount(2, $routes);
}
public function testGroupCanBeCreatedWithoutRouterAndStillWorks(): void
{
// This tests the case where RouteGroup might be used partially or in isolation
// although buildFullPath is the main logic.
$group = new RouteGroup(['prefix' => '/api']);
// Use reflection or just check options if public (it is protected/private)
$this->assertSame(['prefix' => '/api'], $group->getOptions());
}
public function testSetOptionOnGroup(): void
{
$group = new RouteGroup();
$group->setOption('prefix', '/test');
$this->assertSame(['prefix' => '/test'], $group->getOptions());
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Router\Router;
use Atlas\Config\Config;
use PHPUnit\Framework\TestCase;
class RouterFallbackTest extends TestCase
{
public function testFallbackHandlerCanBeSet(): void
{
$config = new Config(['modules_path' => ['/path/to/modules']]);
$router = new Router($config);
$handler = function() { return '404'; };
$router->fallback($handler);
// Use reflection to check if fallbackHandler is set correctly since there is no getter
$reflection = new \ReflectionClass($router);
$property = $reflection->getProperty('fallbackHandler');
$property->setAccessible(true);
$this->assertSame($handler, $property->getValue($router));
}
public function testFallbackReturnsRouterInstanceForChaining(): void
{
$config = new Config(['modules_path' => ['/path/to/modules']]);
$router = new Router($config);
$result = $router->fallback('Handler');
$this->assertSame($router, $result);
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace Atlas\Tests\Unit;
use Atlas\Router\Router;
use Atlas\Config\Config;
use Atlas\Exception\RouteNotFoundException;
use PHPUnit\Framework\TestCase;
class RouterUrlTest extends TestCase
{
private Router $router;
protected function setUp(): void
{
$config = new Config(['modules_path' => ['/path/to/modules']]);
$this->router = new Router($config);
}
public function testUrlGeneratesForStaticRoute(): void
{
$this->router->get('/users', 'handler', 'user_list');
$url = $this->router->url('user_list');
$this->assertSame('/users', $url);
}
public function testUrlGeneratesWithParameters(): void
{
$this->router->get('/users/{{user_id}}', 'handler', 'user_detail');
$url = $this->router->url('user_detail', ['user_id' => 42]);
$this->assertSame('/users/42', $url);
}
public function testUrlGeneratesWithMultipleParameters(): void
{
$this->router->get('/posts/{{post_id}}/comments/{{comment_id}}', 'handler', 'comment_detail');
$url = $this->router->url('comment_detail', [
'post_id' => 10,
'comment_id' => 5
]);
$this->assertSame('/posts/10/comments/5', $url);
}
public function testUrlThrowsExceptionWhenRouteNotFound(): void
{
$this->expectException(RouteNotFoundException::class);
$this->expectExceptionMessage('Route "non_existent" not found');
$this->router->url('non_existent');
}
public function testUrlWithMissingParametersThrowsException(): void
{
$this->router->get('/users/{{user_id}}', 'handler', 'user_detail');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Missing required parameter "user_id"');
$this->router->url('user_detail', []);
}
}