diff --git a/.gitignore b/.gitignore index 255f5fe6..b394cb87 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ composer.lock .phpunit.result.cache bob .phpunit.cache +.vscode +.idea diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..6c5fbb60 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,207 @@ +# Roadmap BowPHP Framework + +> Document évolutif basé sur l'analyse du code source (branche 5.x) et le manifeste du projet. +> Dernière mise à jour : Janvier 2026 + +--- + +## État Actuel du Framework + +### Modules Existants (Analyse du `/src`) + +| Module | Statut | Description | +| ---------------------- | --------- | ---------------------------------------------- | +| **Application** | ✅ Stable | Bootstrap, exception handling, kernel | +| **Auth** | ✅ Stable | Guards (Session, JWT), Authentication | +| **Cache** | ✅ Stable | Adapters: Database, Filesystem, Redis | +| **Configuration** | ✅ Stable | Loader, Env, Logger configuration | +| **Console** | ✅ Stable | 26 commandes, générateurs, stubs | +| **Container** | ✅ Stable | DI container, middleware dispatcher | +| **Database/Barry ORM** | ✅ Stable | MySQL, PostgreSQL, SQLite + Relations | +| **Event** | ✅ Stable | Event dispatcher, listeners, queue integration | +| **Http** | ✅ Stable | Request, Response, Client, Exceptions | +| **Mail** | ✅ Stable | SMTP, Native adapters, queue support | +| **Messaging** | ✅ Stable | SMS, Mail, Slack, Telegram, Database channels | +| **Middleware** | ✅ Stable | Auth, CSRF, Base middleware | +| **Queue** | ✅ Stable | Beanstalkd, Database, SQS, Sync adapters | +| **Router** | ✅ Stable | REST methods, prefixes, middlewares, resources | +| **Security** | ✅ Stable | Crypto, Hash, Sanitize, Tokenize | +| **Session** | ✅ Stable | Cookie, File, Database, Redis adapters | +| **Storage** | ✅ Stable | Disk, FTP, S3 services | +| **Support** | ✅ Stable | Helpers, Collection, Str, Log, Env | +| **Testing** | ✅ Stable | TestCase, Assertions, KernelTesting | +| **Translate** | ✅ Stable | i18n support | +| **Validation** | ✅ Stable | Règles de validation, messages custom | +| **View** | ✅ Stable | Tintin (default), Twig support | + +### Dépendances Actuelles + +**Requises :** + +- PHP ^8.1 +- bowphp/tintin ^3.0 (template engine) +- filp/whoops ^2.1 (error handling) +- nesbot/carbon 3.8.4 (dates) +- fakerphp/faker ^1.20 (testing data) +- ramsey/uuid ^4.7 (UUIDs) + +**Dev/Suggérées :** + +- pda/pheanstalk ^5.0 (Beanstalkd) +- aws/aws-sdk-php ^3.87 (S3) +- bowphp/policier ^3.0 (JWT) +- predis/predis ^2.1 (Redis) +- twilio/sdk ^8.3 (SMS) +- bowphp/slack-webhook ^1.0 (Slack) + +--- + +## 🔴 NOW — 0 à 3 mois (Stabilisation & Consolidation) + +### Tests et CI/CD + +| Tâche | Statut | Priorité | Notes | +| --------------------------------------------------- | ---------- | -------- | ------------------------------------------------------ | +| Séparer les tests unitaires des tests d'intégration | ⏳ À faire | Haute | Les tests DB/FTP/S3 nécessitent des services externes | +| Ajouter `@group` PHPUnit pour isoler les tests | ⏳ À faire | Haute | `@group unit`, `@group integration`, `@group database` | +| Configurer GitHub Actions avec services Docker | ⏳ À faire | Haute | MySQL, PostgreSQL, Redis pour CI | +| Augmenter couverture tests unitaires > 80% | ⏳ À faire | Moyenne | Focus sur modules critiques | +| Intégrer PHPStan niveau 5+ | ⏳ À faire | Moyenne | Actuellement niveau 0.12.87 | + +### Corrections de Code + +| Tâche | Statut | Priorité | Notes | +| ----------------------------------------------- | ---------- | -------- | ------------------------------------- | +| Fixer les tests SQLite qui échouent (isolation) | ⏳ À faire | Haute | Problème de state partagé entre tests | +| Uniformiser les signatures de méthodes | ✅ Fait | - | PHP 8.1+ nullable types | +| Fixer le cast `(double)` → `(float)` | ✅ Fait | - | Model.php ligne 924 | +| Gérer `array_key_exists` avec clé null | ✅ Fait | - | Console.php | +| Créer le répertoire de test si inexistant | ✅ Fait | - | CustomCommand.php | + +### Documentation + +| Tâche | Statut | Priorité | Notes | +| -------------------------------------------- | ---------- | -------- | -------------------------- | +| Mettre à jour README avec exemples API-first | ⏳ À faire | Moyenne | Aligner avec le manifeste | +| Documenter les configurations requises | ⏳ À faire | Moyenne | Chaque module | +| Créer guide de contribution détaillé | ⏳ À faire | Basse | Au-delà du CONTRIBUTING.md | + +--- + +## 🟠 NEXT — 3 à 6 mois (Nouvelles Fonctionnalités) + +### Queue - Adapter Redis + +| Tâche | Statut | Priorité | Notes | +| ---------------------------------------- | ---------- | -------- | ------------------------------ | +| Créer `RedisAdapter` pour Queue | ⏳ À faire | Haute | predis/predis déjà en dev-deps | +| Implémenter delayed jobs avec Redis ZADD | ⏳ À faire | Haute | | +| Ajouter monitoring des queues via CLI | ⏳ À faire | Moyenne | `bow queue:status` | + +### Router - Attributs PHP 8 + +| Tâche | Statut | Priorité | Notes | +| ------------------------------------------------------ | ---------- | -------- | --------------------- | +| Créer namespace `Bow\Router\Attributes` | ⏳ À faire | Haute | | +| Implémenter `#[Controller]` | ⏳ À faire | Haute | prefix, middleware | +| Implémenter `#[Get]`, `#[Post]`, `#[Put]`, `#[Delete]` | ⏳ À faire | Haute | | +| Ajouter `$router->register(Controller::class)` | ⏳ À faire | Haute | Auto-discovery routes | + +### Cache - Adapter Memcached + +| Tâche | Statut | Priorité | Notes | +| --------------------------------------------- | ---------- | -------- | ----- | +| Créer `MemcachedAdapter` | ⏳ À faire | Moyenne | | +| Améliorer résilience Redis (reconnexion auto) | ⏳ À faire | Moyenne | | + +### Messaging - Push Notifications + +| Tâche | Statut | Priorité | Notes | +| ------------------------------------ | ---------- | -------- | ------------- | +| Créer `FcmChannelAdapter` (Firebase) | ⏳ À faire | Moyenne | | +| Créer `ApnsChannelAdapter` (Apple) | ⏳ À faire | Moyenne | | +| Améliorer `TelegramChannelAdapter` | ⏳ À faire | Basse | Déjà existant | +| Améliorer `SlackChannelAdapter` | ⏳ À faire | Basse | Déjà existant | + +### Database + +| Tâche | Statut | Priorité | Notes | +| ----------------------------------------- | ---------- | -------- | ---------------------------- | +| Ajouter support SQL Server | ⏳ À faire | Moyenne | | +| Créer adapter Array/FileWriter pour tests | ⏳ À faire | Moyenne | Évite dépendance DB en tests | + +--- + +## 🟢 LATER — 6 à 12 mois (Vision Long Terme) + +### Performance et Modernisation + +| Tâche | Statut | Priorité | Notes | +| -------------------------------------------- | ---------- | -------- | -------------------------- | +| Support Swoole/FrankenPHP | ⏳ À faire | Moyenne | Serveurs non-bloquants | +| Images Docker officielles | ⏳ À faire | Moyenne | Optimisées pour production | +| Support serverless (Lambda, Cloud Functions) | ⏳ À faire | Basse | HTTP Handler adapté | + +### Écosystème + +| Tâche | Statut | Priorité | Notes | +| ------------------------------------------------ | ---------- | -------- | -------------------- | +| Package `bowphp/payment` | ⏳ À faire | Haute | Mobile money Afrique | +| Package `bowphp/logviewer` ou `bowphp/telescope` | ⏳ À faire | Moyenne | Observabilité | +| Adapter laravel-notify pour Bow | ⏳ À faire | Basse | UI notifications | + +### Observabilité + +| Tâche | Statut | Priorité | Notes | +| ------------------------------ | ---------- | -------- | ------------------------------- | +| Module OpenTelemetry optionnel | ⏳ À faire | Moyenne | Tracing requests, jobs, queries | +| Intégration Prometheus/Grafana | ⏳ À faire | Basse | Métriques production | + +--- + +## Légende + +- ✅ **Fait** : Tâche complétée +- ⏳ **À faire** : Tâche planifiée +- 🔄 **En cours** : Travail en progression +- ❌ **Annulé** : Tâche abandonnée + +--- + +## Comment Contribuer + +1. Choisir une tâche de la section **NOW** (priorité haute) +2. Ouvrir une issue pour discuter de l'implémentation +3. Créer une branche `feature/nom-de-la-tache` +4. Suivre les conventions du projet (voir CONTRIBUTING.md) +5. Soumettre une PR avec tests + +--- + +## Notes Importantes + +### Concernant les Tests + +Les erreurs actuelles lors de `composer test` sont principalement dues à : + +1. **Services externes non disponibles** (pas des bugs du framework) : + + - MySQL : Connection refused / Access denied + - PostgreSQL : Connection refused + - FTP : Connection refused + - S3 : Invalid endpoint + - Beanstalkd : Connection refused + +2. **Isolation des tests SQLite** : Certains tests partagent l'état de la base, causant des échecs intermittents. + +**Solution recommandée** : Séparer les tests en groupes (`@group unit`, `@group integration`) et configurer CI avec Docker Compose pour les tests d'intégration. + +### Philosophie du Projet + +Toute contribution doit respecter le manifeste : + +- **Simplicité** > Sophistication +- **Lisibilité** > Concision extrême +- **API-first** : Priorité aux backends JSON +- **Performance** : Bootstrap minimal, réponse rapide +- **Contrôle** : Le développeur garde le contrôle de son architecture diff --git a/src/Configuration/EnvConfiguration.php b/src/Configuration/EnvConfiguration.php index f4dd4d7d..e792bcc3 100644 --- a/src/Configuration/EnvConfiguration.php +++ b/src/Configuration/EnvConfiguration.php @@ -13,7 +13,21 @@ class EnvConfiguration extends Configuration */ public function create(Loader $config): void { - Env::configure(base_path('.env.json')); + $envFile = $config->getBasePath() . '/.env.json'; + + // Check if environment is already loaded + try { + $env = Env::getInstance(); + if ($env->isLoaded()) { + $this->container->instance('env', $env); + return; + } + } catch (\Bow\Application\Exception\ApplicationException $e) { + // Environment not loaded, continue to load it + } + + // Load environment - will throw exception if file doesn't exist + Env::configure($envFile); $event = Env::getInstance(); diff --git a/src/Console/Console.php b/src/Console/Console.php index 3b080b75..18e53734 100644 --- a/src/Console/Console.php +++ b/src/Console/Console.php @@ -243,8 +243,9 @@ public function call(?string $command): mixed if (!in_array($command, array_keys($commands))) { // Try to execute the custom command - if (array_key_exists($this->arg->getRawCommand(), static::$registers) || array_key_exists($command, static::$registers)) { - return $this->executeCustomCommand($this->arg->getRawCommand() ?? $command); + $rawCommand = $this->arg->getRawCommand() ?? ''; + if (($rawCommand !== '' && array_key_exists($rawCommand, static::$registers)) || array_key_exists($command, static::$registers)) { + return $this->executeCustomCommand($rawCommand ?: $command); } } diff --git a/src/Database/Barry/Model.php b/src/Database/Barry/Model.php index 813ac981..f4b44271 100644 --- a/src/Database/Barry/Model.php +++ b/src/Database/Barry/Model.php @@ -921,7 +921,7 @@ public function __get(string $name): mixed return (float)$value; } if ($type === "double") { - return (double)$value; + return (float)$value; } if ($type === "json") { if (is_array($value)) { diff --git a/src/Router/AttributeRouteRegistrar.php b/src/Router/AttributeRouteRegistrar.php new file mode 100644 index 00000000..814defb6 --- /dev/null +++ b/src/Router/AttributeRouteRegistrar.php @@ -0,0 +1,106 @@ +router = $router; + } + + /** + * Register routes from controller classes + * + * @param string|array $controllers + * @return void + */ + public function register(string|array $controllers): void + { + $controllers = is_array($controllers) ? $controllers : [$controllers]; + + foreach ($controllers as $controller) { + $this->registerController($controller); + } + } + + /** + * Register routes from controller + * + * @param string $controllerClass + * @return void + */ + private function registerController(string $controllerClass): void + { + $reflection = new ReflectionClass($controllerClass); + + // Get controller attribute + $controllerAttributes = $reflection->getAttributes(Controller::class); + $controllerAttribute = !empty($controllerAttributes) ? $controllerAttributes[0]->newInstance() : null; + + $prefix = $controllerAttribute?->getPrefix() ?? ''; + $controllerMiddleware = $controllerAttribute?->getMiddleware() ?? []; + + // Scan methods + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if (str_starts_with($method->getName(), '__')) { + continue; + } + + // Get route attributes + $routeAttributes = $method->getAttributes( + RouteAttribute::class, + \ReflectionAttribute::IS_INSTANCEOF + ); + + foreach ($routeAttributes as $attribute) { + /** @var RouteAttribute $routeAttr */ + $routeAttr = $attribute->newInstance(); + + // Build path + $routePath = $routeAttr->getPath(); + $routePath = '/' . ltrim($routePath, '/'); + $fullPath = $prefix !== '' ? rtrim($prefix, '/') . $routePath : $routePath; + + // Merge middleware + $middleware = array_merge($controllerMiddleware, $routeAttr->getMiddleware()); + + // Register route + $route = $this->router->match( + $routeAttr->getMethods(), + $fullPath, + [$controllerClass, $method->getName()] + ); + + if (!empty($middleware)) { + $route->middleware($middleware); + } + + if (!empty($routeAttr->getWhere())) { + $route->where($routeAttr->getWhere()); + } + + if ($routeAttr->getName() !== null) { + $route->name($routeAttr->getName()); + } + } + } + } +} diff --git a/src/Router/Attributes/Controller.php b/src/Router/Attributes/Controller.php new file mode 100644 index 00000000..f7f366d9 --- /dev/null +++ b/src/Router/Attributes/Controller.php @@ -0,0 +1,52 @@ +prefix; + } + + /** + * Get the middleware + * + * @return array + */ + public function getMiddleware(): array + { + return $this->middleware; + } + + /** + * Get the route name + * + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } +} diff --git a/src/Router/Attributes/Delete.php b/src/Router/Attributes/Delete.php new file mode 100644 index 00000000..375cc5dd --- /dev/null +++ b/src/Router/Attributes/Delete.php @@ -0,0 +1,27 @@ +path; + } + + /** + * Get the http methods + * + * @return array + */ + public function getMethods(): array + { + return array_map('strtoupper', $this->methods); + } + + /** + * Get the middleware + * + * @return array + */ + public function getMiddleware(): array + { + return $this->middleware; + } + + /** + * Get the route constraints + * + * @return array + */ + public function getWhere(): array + { + return $this->where; + } + + /** + * Get the route name + * + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } +} + diff --git a/src/Router/Router.php b/src/Router/Router.php index 0e364b37..726268ca 100644 --- a/src/Router/Router.php +++ b/src/Router/Router.php @@ -491,4 +491,18 @@ public function setCurrentPath(string $path): void { $this->current['path'] = $path; } + + /** + * Register routes from controller classes + * + * @param string|array $controllers + * @return Router + */ + public function register(string|array $controllers): Router + { + $registrar = new AttributeRouteRegistrar($this); + $registrar->register($controllers); + + return $this; + } } diff --git a/src/Support/helpers.php b/src/Support/helpers.php index b63ad27a..6d5e1b61 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -1186,10 +1186,14 @@ function __( */ function app_env(string $key, mixed $default = null): ?string { - $env = Env::getInstance(); + try { + $env = Env::getInstance(); - if ($env->isLoaded()) { - return $env->get($key, $default); + if ($env->isLoaded()) { + return $env->get($key, $default); + } + } catch (\Bow\Application\Exception\ApplicationException $e) { + // Environment not loaded, return default } return $default; diff --git a/tests/Console/Stubs/CustomCommand.php b/tests/Console/Stubs/CustomCommand.php index 3053aad1..551d445d 100644 --- a/tests/Console/Stubs/CustomCommand.php +++ b/tests/Console/Stubs/CustomCommand.php @@ -8,6 +8,10 @@ class CustomCommand extends ConsoleCommand { public function process() { - file_put_contents(TESTING_RESOURCE_BASE_DIRECTORY . '/test_custom_command.txt', 'ok'); + $directory = TESTING_RESOURCE_BASE_DIRECTORY; + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + file_put_contents($directory . '/test_custom_command.txt', 'ok'); } } diff --git a/tests/Routing/AttributeRouteIntegrationTest.php b/tests/Routing/AttributeRouteIntegrationTest.php new file mode 100644 index 00000000..ba9582ff --- /dev/null +++ b/tests/Routing/AttributeRouteIntegrationTest.php @@ -0,0 +1,181 @@ +router = Router::configure(); + } + + public function test_registrar_registers_routes_from_controller(): void + { + $registrar = new AttributeRouteRegistrar($this->router); + $registrar->register(UserControllerStub::class); + + $routes = $this->router->getRoutes(); + + // Check that routes were registered + $this->assertArrayHasKey('GET', $routes); + $this->assertArrayHasKey('POST', $routes); + $this->assertArrayHasKey('PUT', $routes); + $this->assertArrayHasKey('DELETE', $routes); + $this->assertArrayHasKey('PATCH', $routes); + } + + public function test_registrar_registers_routes_with_correct_paths(): void + { + $registrar = new AttributeRouteRegistrar($this->router); + $registrar->register(UserControllerStub::class); + + $routes = $this->router->getRoutes(); + + // Get the registered GET routes + $getRoutes = $routes['GET'] ?? []; + + // Check that we have at least the expected routes + $this->assertGreaterThanOrEqual(2, count($getRoutes)); + + // Get paths from routes + $paths = array_map(fn($route) => $route->getPath(), $getRoutes); + + // Check if the path starts with /api/users + $hasIndexRoute = false; + $hasShowRoute = false; + foreach ($paths as $path) { + if ($path === '/api/users/' || $path === '/api/users') { + $hasIndexRoute = true; + } + if (str_contains($path, '/api/users/:id') || str_contains($path, '/api/users/')) { + $hasShowRoute = true; + } + } + $this->assertTrue($hasIndexRoute, 'Index route should be registered'); + $this->assertTrue($hasShowRoute, 'Show route should be registered'); + } + + public function test_registrar_handles_controller_without_controller_attribute(): void + { + $registrar = new AttributeRouteRegistrar($this->router); + $registrar->register(SimpleControllerStub::class); + + $routes = $this->router->getRoutes(); + + // Should still register routes + $this->assertArrayHasKey('GET', $routes); + $this->assertArrayHasKey('POST', $routes); + } + + public function test_router_register_method_works(): void + { + $this->router->register(UserControllerStub::class); + + $routes = $this->router->getRoutes(); + + $this->assertArrayHasKey('GET', $routes); + $this->assertNotEmpty($routes['GET']); + } + + public function test_router_register_accepts_array_of_controllers(): void + { + $this->router->register([ + UserControllerStub::class, + SimpleControllerStub::class + ]); + + $routes = $this->router->getRoutes(); + + // Get all registered paths + $allPaths = []; + foreach ($routes as $methodRoutes) { + foreach ($methodRoutes as $route) { + $allPaths[] = $route->getPath(); + } + } + + // Check that routes from both controllers are registered + $hasUserRoute = false; + $hasSimpleRoute = false; + foreach ($allPaths as $path) { + if (str_starts_with($path, '/api/users')) { + $hasUserRoute = true; + } + if (str_contains($path, '/simple')) { + $hasSimpleRoute = true; + } + } + $this->assertTrue($hasUserRoute, 'User controller routes should be registered'); + $this->assertTrue($hasSimpleRoute, 'Simple controller routes should be registered'); + } + + public function test_router_register_returns_router_for_chaining(): void + { + $result = $this->router->register(UserControllerStub::class); + + $this->assertInstanceOf(Router::class, $result); + } + + public function test_route_middleware_is_applied_correctly(): void + { + $this->router->register(UserControllerStub::class); + + $routes = $this->router->getRoutes(); + $postRoutes = $routes['POST'] ?? []; + + // Find the store route + $storeRoute = null; + foreach ($postRoutes as $route) { + if (str_contains($route->getPath(), '/api/users')) { + $storeRoute = $route; + break; + } + } + + $this->assertNotNull($storeRoute); + + // The action should contain middleware + $action = $storeRoute->getAction(); + $this->assertIsArray($action); + $this->assertArrayHasKey('middleware', $action); + + // Should have both controller and route middleware + $middleware = $action['middleware']; + $this->assertContains('auth', $middleware); + $this->assertContains('validate', $middleware); + } +} diff --git a/tests/Routing/AttributeRouteTest.php b/tests/Routing/AttributeRouteTest.php new file mode 100644 index 00000000..b6b3d01e --- /dev/null +++ b/tests/Routing/AttributeRouteTest.php @@ -0,0 +1,188 @@ + '[0-9]+'], name: 'users.index'); + + $this->assertEquals('/users', $get->getPath()); + $this->assertEquals(['GET'], $get->getMethods()); + $this->assertEquals(['auth'], $get->getMiddleware()); + $this->assertEquals(['id' => '[0-9]+'], $get->getWhere()); + $this->assertEquals('users.index', $get->getName()); + } + + public function test_post_attribute_creates_correct_route(): void + { + $post = new Post('/users'); + + $this->assertEquals('/users', $post->getPath()); + $this->assertEquals(['POST'], $post->getMethods()); + } + + public function test_put_attribute_creates_correct_route(): void + { + $put = new Put('/users/:id'); + + $this->assertEquals('/users/:id', $put->getPath()); + $this->assertEquals(['PUT'], $put->getMethods()); + } + + public function test_delete_attribute_creates_correct_route(): void + { + $delete = new Delete('/users/:id'); + + $this->assertEquals('/users/:id', $delete->getPath()); + $this->assertEquals(['DELETE'], $delete->getMethods()); + } + + public function test_patch_attribute_creates_correct_route(): void + { + $patch = new Patch('/users/:id'); + + $this->assertEquals('/users/:id', $patch->getPath()); + $this->assertEquals(['PATCH'], $patch->getMethods()); + } + + public function test_options_attribute_creates_correct_route(): void + { + $options = new Options('/users'); + + $this->assertEquals('/users', $options->getPath()); + $this->assertEquals(['OPTIONS'], $options->getMethods()); + } + + public function test_route_attribute_with_multiple_methods(): void + { + $route = new Route('/users', methods: ['GET', 'post', 'PUT']); + + $this->assertEquals('/users', $route->getPath()); + $this->assertEquals(['GET', 'POST', 'PUT'], $route->getMethods()); + } + + // ===== Controller Attribute Tests ===== + + public function test_controller_attribute_with_prefix_and_middleware(): void + { + $controller = new Controller(prefix: '/api/v1', middleware: ['auth', 'throttle'], name: 'api'); + + $this->assertEquals('/api/v1', $controller->getPrefix()); + $this->assertEquals(['auth', 'throttle'], $controller->getMiddleware()); + $this->assertEquals('api', $controller->getName()); + } + + public function test_controller_attribute_defaults(): void + { + $controller = new Controller(); + + $this->assertEquals('', $controller->getPrefix()); + $this->assertEquals([], $controller->getMiddleware()); + $this->assertNull($controller->getName()); + } + + // ===== Reflection Tests ===== + + public function test_user_controller_has_controller_attribute(): void + { + $reflection = new ReflectionClass(UserControllerStub::class); + $attributes = $reflection->getAttributes(Controller::class); + + $this->assertCount(1, $attributes); + + /** @var Controller $controller */ + $controller = $attributes[0]->newInstance(); + + $this->assertEquals('/api/users', $controller->getPrefix()); + $this->assertEquals(['auth'], $controller->getMiddleware()); + } + + public function test_user_controller_methods_have_route_attributes(): void + { + $reflection = new ReflectionClass(UserControllerStub::class); + + // Test index method + $indexMethod = $reflection->getMethod('index'); + $indexAttributes = $indexMethod->getAttributes(Get::class); + $this->assertCount(1, $indexAttributes); + + /** @var Get $getAttr */ + $getAttr = $indexAttributes[0]->newInstance(); + $this->assertEquals('/', $getAttr->getPath()); + + // Test store method + $storeMethod = $reflection->getMethod('store'); + $storeAttributes = $storeMethod->getAttributes(Post::class); + $this->assertCount(1, $storeAttributes); + + /** @var Post $postAttr */ + $postAttr = $storeAttributes[0]->newInstance(); + $this->assertEquals('/', $postAttr->getPath()); + $this->assertEquals(['validate'], $postAttr->getMiddleware()); + } + + public function test_can_get_all_route_attributes_using_instanceof(): void + { + $reflection = new ReflectionClass(UserControllerStub::class); + $indexMethod = $reflection->getMethod('index'); + + // Get all Route attributes (including subclasses like Get, Post, etc.) + $routeAttributes = $indexMethod->getAttributes( + Route::class, + \ReflectionAttribute::IS_INSTANCEOF + ); + + $this->assertCount(1, $routeAttributes); + } + + public function test_route_attribute_middleware_merges_correctly(): void + { + $route = new Get('/test', middleware: ['first', 'second']); + + $this->assertEquals(['first', 'second'], $route->getMiddleware()); + } + + public function test_route_attribute_where_constraints(): void + { + $route = new Get('/users/:id/:slug', where: ['id' => '[0-9]+', 'slug' => '[a-z-]+']); + + $this->assertEquals([ + 'id' => '[0-9]+', + 'slug' => '[a-z-]+' + ], $route->getWhere()); + } + + public function test_all_http_attributes_extend_route(): void + { + $this->assertInstanceOf(Route::class, new Get('/')); + $this->assertInstanceOf(Route::class, new Post('/')); + $this->assertInstanceOf(Route::class, new Put('/')); + $this->assertInstanceOf(Route::class, new Delete('/')); + $this->assertInstanceOf(Route::class, new Patch('/')); + $this->assertInstanceOf(Route::class, new Options('/')); + } +} diff --git a/tests/Routing/Stubs/SimpleControllerStub.php b/tests/Routing/Stubs/SimpleControllerStub.php new file mode 100644 index 00000000..41f508eb --- /dev/null +++ b/tests/Routing/Stubs/SimpleControllerStub.php @@ -0,0 +1,27 @@ + 'simple_index']; + } + + #[Post('/simple', name: 'simple.store')] + public function store(): array + { + return ['action' => 'simple_store']; + } +} + diff --git a/tests/Routing/Stubs/UserControllerStub.php b/tests/Routing/Stubs/UserControllerStub.php new file mode 100644 index 00000000..cb27ec85 --- /dev/null +++ b/tests/Routing/Stubs/UserControllerStub.php @@ -0,0 +1,57 @@ + 'index']; + } + + #[Get('/:id', where: ['id' => '[0-9]+'])] + public function show(Request $request): array + { + return ['action' => 'show', 'id' => $request->get('id')]; + } + + #[Post('/', middleware: ['validate'])] + public function store(Request $request): array + { + return ['action' => 'store']; + } + + #[Put('/:id')] + public function update(Request $request): array + { + return ['action' => 'update', 'id' => $request->get('id')]; + } + + #[Patch('/:id')] + public function patch(Request $request): array + { + return ['action' => 'patch', 'id' => $request->get('id')]; + } + + #[Delete('/:id', middleware: ['admin'])] + public function destroy(Request $request): array + { + return ['action' => 'destroy', 'id' => $request->get('id')]; + } +} +