Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 100 additions & 11 deletions src/Command/TestCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class TestCommand extends BakeCommand
'Command' => 'Command',
'CommandHelper' => 'Command\Helper',
'Middleware' => 'Middleware',
'Class' => '',
];

/**
Expand All @@ -75,6 +76,7 @@ class TestCommand extends BakeCommand
'Command' => 'Command',
'CommandHelper' => 'Helper',
'Middleware' => 'Middleware',
'Class' => '',
];

/**
Expand Down Expand Up @@ -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');
}

Expand Down Expand Up @@ -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);
Expand All @@ -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(" <info>bin/cake bake test class '{$className}'</info>");
$io->out('');
$io->out('Or specify without the base namespace:');
$io->out(' <info>bin/cake bake test class YourNamespace\\ClassName</info>');

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();

Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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];
}
Expand Down Expand Up @@ -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,
Expand Down
166 changes: 166 additions & 0 deletions tests/TestCase/Command/TestCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,7 @@ public static function mapTypeProvider()
['Entity', 'Model\Entity'],
['Behavior', 'Model\Behavior'],
['Helper', 'View\Helper'],
['Class', ''],
];
}

Expand Down Expand Up @@ -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'");
}
}
Loading