diff --git a/src/Command/TestCommand.php b/src/Command/TestCommand.php index be2c4c28..f48e99e2 100644 --- a/src/Command/TestCommand.php +++ b/src/Command/TestCommand.php @@ -55,6 +55,7 @@ class TestCommand extends BakeCommand 'Command' => 'Command', 'CommandHelper' => 'Command\Helper', 'Middleware' => 'Middleware', + 'Class' => '', ]; /** @@ -75,6 +76,7 @@ class TestCommand extends BakeCommand 'Command' => 'Command', 'CommandHelper' => 'Helper', 'Middleware' => 'Middleware', + 'Class' => '', ]; /** @@ -123,7 +125,11 @@ public function execute(Arguments $args, ConsoleIo $io): ?int $name = $args->getArgument('name'); $name = $this->_getName($name); - if ($this->bake($type, $name, $args, $io)) { + $result = $this->bake($type, $name, $args, $io); + if ($result === static::CODE_ERROR) { + return static::CODE_ERROR; + } + if ($result) { $io->success('Done'); } @@ -212,10 +218,29 @@ protected function _getClassOptions(string $namespace): array } $path = $base . str_replace('\\', DS, $namespace); - $files = (new Filesystem())->find($path); - foreach ($files as $fileObj) { - if ($fileObj->isFile()) { - $classes[] = substr($fileObj->getFileName(), 0, -4) ?: ''; + + // For generic Class type (empty namespace), search recursively + if ($namespace === '') { + $files = (new Filesystem())->findRecursive($path, '/\.php$/'); + foreach ($files as $fileObj) { + if ($fileObj->isFile() && $fileObj->getFileName() !== 'Application.php') { + // Build the namespace path relative to App directory + $relativePath = str_replace($base, '', $fileObj->getPath()); + $relativePath = trim(str_replace(DS, '\\', $relativePath), '\\'); + $className = substr($fileObj->getFileName(), 0, -4) ?: ''; + if ($relativePath) { + $classes[] = $relativePath . '\\' . $className; + } else { + $classes[] = $className; + } + } + } + } else { + $files = (new Filesystem())->find($path); + foreach ($files as $fileObj) { + if ($fileObj->isFile()) { + $classes[] = substr($fileObj->getFileName(), 0, -4) ?: ''; + } } } sort($classes); @@ -230,18 +255,43 @@ protected function _getClassOptions(string $namespace): array * @param string $className the 'cake name' for the class ie. Posts for the PostsController * @param \Cake\Console\Arguments $args Arguments * @param \Cake\Console\ConsoleIo $io ConsoleIo instance - * @return string|bool + * @return string|bool|int Returns the generated code as string on success, false on failure, or CODE_ERROR for validation errors */ - public function bake(string $type, string $className, Arguments $args, ConsoleIo $io): string|bool + public function bake(string $type, string $className, Arguments $args, ConsoleIo $io): string|bool|int { $type = $this->normalize($type); if (!isset($this->classSuffixes[$type]) || !isset($this->classTypes[$type])) { return false; } + // For Class type, validate that backslashes are properly escaped + if ($type === 'Class' && !str_contains($className, '\\')) { + $io->error('Class name appears to have no namespace separators.'); + $io->out(''); + $io->out('If you meant to specify a namespaced class, please use quotes:'); + $io->out(" bin/cake bake test class '{$className}'"); + $io->out(''); + $io->out('Or specify without the base namespace:'); + $io->out(' bin/cake bake test class YourNamespace\\ClassName'); + + return static::CODE_ERROR; + } + $prefix = $this->getPrefix($args); $fullClassName = $this->getRealClassName($type, $className, $prefix); + // For Class type, validate that the class exists + if ($type === 'Class' && !class_exists($fullClassName)) { + $io->error("Class '{$fullClassName}' does not exist or cannot be loaded."); + $io->out(''); + $io->out('Please check:'); + $io->out(' - The class file exists in the correct location'); + $io->out(' - The class is properly autoloaded'); + $io->out(' - The namespace and class name are correct'); + + return static::CODE_ERROR; + } + // Check if fixture factories plugin is available $hasFixtureFactories = $this->hasFixtureFactories(); @@ -266,8 +316,14 @@ public function bake(string $type, string $className, Arguments $args, ConsoleIo [$preConstruct, $construction, $postConstruct] = $this->generateConstructor($type, $fullClassName); $uses = $this->generateUses($type, $fullClassName); - $subject = $className; - [$namespace, $className] = namespaceSplit($fullClassName); + // For generic Class type, extract just the class name for the subject + if ($type === 'Class') { + [$namespace, $className] = namespaceSplit($fullClassName); + $subject = $className; + } else { + $subject = $className; + [$namespace, $className] = namespaceSplit($fullClassName); + } $baseNamespace = Configure::read('App.namespace'); if ($this->plugin) { @@ -381,6 +437,17 @@ public function getRealClassName(string $type, string $class, ?string $prefix = if ($this->plugin) { $namespace = str_replace('/', '\\', $this->plugin); } + + // For generic Class type, the class name contains the full subnamespace path + if ($type === 'Class') { + // Strip base namespace if user included it + if (str_starts_with($class, $namespace . '\\')) { + $class = substr($class, strlen($namespace) + 1); + } + + return $namespace . '\\' . $class; + } + $suffix = $this->classSuffixes[$type]; $subSpace = $this->mapType($type); if ($suffix && strpos($class, $suffix) === false) { @@ -415,7 +482,7 @@ public function getSubspacePath(string $type): string */ public function mapType(string $type): string { - if (empty($this->classTypes[$type])) { + if (!isset($this->classTypes[$type])) { throw new CakeException('Invalid object type: ' . $type); } @@ -585,6 +652,18 @@ public function generateConstructor(string $type, string $fullClassName): array $pre .= ' $this->io = new ConsoleIo($this->stub);'; $construct = "new {$className}(\$this->io);"; } + if ($type === 'Class') { + // Check if class has required constructor parameters + if (class_exists($fullClassName)) { + $reflection = new ReflectionClass($fullClassName); + $constructor = $reflection->getConstructor(); + if (!$constructor || $constructor->getNumberOfRequiredParameters() === 0) { + $construct = "new {$className}();"; + } + } else { + $construct = "new {$className}();"; + } + } return [$pre, $construct, $post]; } @@ -635,7 +714,17 @@ public function generateProperties(string $type, string $subject, string $fullCl break; } - if (!in_array($type, ['Controller', 'Command'])) { + // Skip test subject property for Controller, Command, and Class types with required constructor params + $skipProperty = in_array($type, ['Controller', 'Command'], true); + if ($type === 'Class' && class_exists($fullClassName)) { + $reflection = new ReflectionClass($fullClassName); + $constructor = $reflection->getConstructor(); + if ($constructor && $constructor->getNumberOfRequiredParameters() > 0) { + $skipProperty = true; + } + } + + if (!$skipProperty) { $properties[] = [ 'description' => 'Test subject', 'type' => '\\' . $fullClassName, diff --git a/tests/TestCase/Command/TestCommandTest.php b/tests/TestCase/Command/TestCommandTest.php index 41db8451..b2552ce6 100644 --- a/tests/TestCase/Command/TestCommandTest.php +++ b/tests/TestCase/Command/TestCommandTest.php @@ -707,6 +707,7 @@ public static function mapTypeProvider() ['Entity', 'Model\Entity'], ['Behavior', 'Model\Behavior'], ['Helper', 'View\Helper'], + ['Class', ''], ]; } @@ -761,4 +762,169 @@ public function testGenerateUsesDocBlockTable() $testsPath . 'TestCase/Model/Table/ProductsTableTest.php', ); } + + /** + * Test baking generic Class type without constructor args + * + * @return void + */ + public function testBakeGenericClassWithoutConstructor() + { + $testsPath = ROOT . 'tests' . DS; + $this->generatedFiles = [ + $testsPath . 'TestCase/Service/SimpleCalculatorTest.php', + ]; + + $this->exec('bake test Class Service\SimpleCalculator', ['y']); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertFilesExist($this->generatedFiles); + $this->assertFileContains( + 'class SimpleCalculatorTest extends TestCase', + $this->generatedFiles[0], + ); + $this->assertFileContains( + 'protected $SimpleCalculator;', + $this->generatedFiles[0], + ); + $this->assertFileContains( + 'protected function setUp(): void', + $this->generatedFiles[0], + ); + $this->assertFileContains( + '$this->SimpleCalculator = new SimpleCalculator();', + $this->generatedFiles[0], + ); + $this->assertFileContains( + 'public function testAdd(): void', + $this->generatedFiles[0], + ); + $this->assertFileContains( + 'public function testSubtract(): void', + $this->generatedFiles[0], + ); + } + + /** + * Test baking generic Class type with required constructor args + * + * @return void + */ + public function testBakeGenericClassWithRequiredConstructor() + { + $testsPath = ROOT . 'tests' . DS; + $this->generatedFiles = [ + $testsPath . 'TestCase/Service/UserServiceTest.php', + ]; + + $this->exec('bake test Class Service\UserService', ['y']); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertFilesExist($this->generatedFiles); + $this->assertFileContains( + 'class UserServiceTest extends TestCase', + $this->generatedFiles[0], + ); + $this->assertFileNotContains( + 'protected UserService $UserService;', + $this->generatedFiles[0], + ); + $this->assertFileNotContains( + 'protected function setUp(): void', + $this->generatedFiles[0], + ); + $this->assertFileNotContains( + 'protected function tearDown(): void', + $this->generatedFiles[0], + ); + $this->assertFileContains( + 'public function testGetUserById(): void', + $this->generatedFiles[0], + ); + $this->assertFileContains( + 'public function testCreateUser(): void', + $this->generatedFiles[0], + ); + } + + /** + * Test that Class type generates correct namespace + * + * @return void + */ + public function testBakeGenericClassNamespace() + { + $testsPath = ROOT . 'tests' . DS; + $this->generatedFiles = [ + $testsPath . 'TestCase/Service/SimpleCalculatorTest.php', + ]; + + $this->exec('bake test Class Service\SimpleCalculator', ['y']); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertFileContains( + 'namespace Bake\Test\App\Test\TestCase\Service;', + $this->generatedFiles[0], + ); + $this->assertFileContains( + 'class SimpleCalculatorTest extends TestCase', + $this->generatedFiles[0], + ); + $this->assertFileNotContains( + 'ServiceSimpleCalculator', + $this->generatedFiles[0], + ); + } + + /** + * Test that Class type handles user including base namespace + * + * @return void + */ + public function testBakeGenericClassWithBaseNamespace() + { + $testsPath = ROOT . 'tests' . DS; + $this->generatedFiles = [ + $testsPath . 'TestCase/Service/UserServiceTest.php', + ]; + + // User includes "Bake\Test\App\" in the class name + $this->exec('bake test Class Bake\Test\App\Service\UserService', ['y']); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertFileContains( + 'namespace Bake\Test\App\Test\TestCase\Service;', + $this->generatedFiles[0], + ); + $this->assertFileContains( + 'class UserServiceTest extends TestCase', + $this->generatedFiles[0], + ); + // Should not have duplicated namespace + $this->assertFileNotContains( + 'namespace Bake\Test\App\Test\TestCase\Bake\Test\App', + $this->generatedFiles[0], + ); + $this->assertFileNotContains( + 'BakeTestAppService', + $this->generatedFiles[0], + ); + } + + /** + * Test that Class type validates backslash escaping + * + * @return void + */ + public function testBakeGenericClassValidatesBackslashes() + { + // Simulate what happens when user doesn't quote: App\Error\ErrorLogger + // Bash strips backslashes resulting in: AppErrorErrorLogger + $this->exec('bake test Class AppErrorErrorLogger'); + + $this->assertExitCode(CommandInterface::CODE_ERROR); + $this->assertErrorContains('Class name appears to have no namespace separators'); + $this->assertOutputContains('please use quotes'); + $this->assertOutputContains("bin/cake bake test class 'AppErrorErrorLogger'"); + } } diff --git a/tests/test_app/App/Service/SimpleCalculator.php b/tests/test_app/App/Service/SimpleCalculator.php new file mode 100644 index 00000000..be1c7082 --- /dev/null +++ b/tests/test_app/App/Service/SimpleCalculator.php @@ -0,0 +1,34 @@ + $id]; + } + + /** + * Create a new user + * + * @param array $data User data + * @return bool Success status + */ + public function createUser(array $data): bool + { + return true; + } +}