diff --git a/src/Console/Commands/ServeCommand.php b/src/Console/Commands/ServeCommand.php index 61f5d3512..6027a158d 100644 --- a/src/Console/Commands/ServeCommand.php +++ b/src/Console/Commands/ServeCommand.php @@ -180,11 +180,7 @@ protected function startPhpServer(string $host, int $port) 'public', ]; - $descriptors = [ - 0 => STDIN, - 1 => STDOUT, - 2 => STDERR, - ]; + $descriptors = $this->serverProcessDescriptors(); $process = proc_open($cmd, $descriptors, $pipes); @@ -195,6 +191,19 @@ protected function startPhpServer(string $host, int $port) return $process; } + /** + * Descriptors used to start the PHP built-in server process. + * @return array|mixed> + */ + protected function serverProcessDescriptors(): array + { + return [ + 0 => STDIN, + 1 => STDOUT, + 2 => STDERR, + ]; + } + /** * Wait until the PHP server is ready to accept connections. * @param resource $process diff --git a/tests/Unit/Console/Commands/DebugBarCommandTest.php b/tests/Unit/Console/Commands/DebugBarCommandTest.php index 44f7a28eb..589a2ea94 100644 --- a/tests/Unit/Console/Commands/DebugBarCommandTest.php +++ b/tests/Unit/Console/Commands/DebugBarCommandTest.php @@ -4,8 +4,9 @@ use Symfony\Component\Console\Tester\CommandTester; use Quantum\Console\Commands\DebugBarCommand; -use Quantum\Storage\FileSystem; use Quantum\Tests\Unit\AppTestCase; +use Quantum\Storage\FileSystem; +use ReflectionMethod; class DebugBarCommandTest extends AppTestCase { @@ -50,4 +51,34 @@ public function testExecShowsErrorWhenAlreadyInstalled(): void @rmdir($assetsPath); @rmdir(assets_dir() . DS . 'DebugBar'); } + + public function testCopyResourcesRecursivelyCopiesFiles(): void + { + $sourceDir = base_dir() . DS . 'var' . DS . 'tmp_debugbar_src_' . uniqid(); + $targetDir = base_dir() . DS . 'var' . DS . 'tmp_debugbar_dst_' . uniqid(); + $nestedSourceDir = $sourceDir . DS . 'nested'; + + mkdir($nestedSourceDir, 0777, true); + mkdir($targetDir, 0777, true); + file_put_contents($sourceDir . DS . 'root.css', 'root'); + file_put_contents($nestedSourceDir . DS . 'nested.js', 'nested'); + + $this->setPrivateProperty($this->command, 'publicDebugBarFolderPath', $targetDir); + + $method = new ReflectionMethod($this->command, 'copyResources'); + $method->setAccessible(true); + $method->invoke($this->command, $sourceDir, $targetDir); + + $this->assertFileExists($targetDir . DS . 'root.css'); + $this->assertFileExists($targetDir . DS . 'nested' . DS . 'nested.js'); + + @unlink($targetDir . DS . 'nested' . DS . 'nested.js'); + @unlink($targetDir . DS . 'root.css'); + @rmdir($targetDir . DS . 'nested'); + @rmdir($targetDir); + @unlink($nestedSourceDir . DS . 'nested.js'); + @unlink($sourceDir . DS . 'root.css'); + @rmdir($nestedSourceDir); + @rmdir($sourceDir); + } } diff --git a/tests/Unit/Console/Commands/MigrationMigrateCommandTest.php b/tests/Unit/Console/Commands/MigrationMigrateCommandTest.php index bfc8b1352..430be19ad 100644 --- a/tests/Unit/Console/Commands/MigrationMigrateCommandTest.php +++ b/tests/Unit/Console/Commands/MigrationMigrateCommandTest.php @@ -3,6 +3,7 @@ namespace Quantum\Tests\Unit\Console\Commands; use Quantum\Console\Commands\MigrationMigrateCommand; +use Quantum\Migration\Enums\ExceptionMessages; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Helper\HelperSet; @@ -48,4 +49,17 @@ public function testExecCancelsDownDirectionWhenNotConfirmed(): void $output = $tester->getDisplay(); $this->assertStringContainsString('Operation was canceled!', $output); } + + public function testExecWithInvalidDirectionShowsMigrationMessage(): void + { + $tester = new CommandTester($this->command); + $tester->execute([ + 'direction' => 'invalid', + ]); + + $this->assertStringContainsString( + ExceptionMessages::WRONG_MIGRATION_DIRECTION, + $tester->getDisplay() + ); + } } diff --git a/tests/Unit/Console/Commands/OpenApiCommandTest.php b/tests/Unit/Console/Commands/OpenApiCommandTest.php index 32756a2a4..3283c4e67 100644 --- a/tests/Unit/Console/Commands/OpenApiCommandTest.php +++ b/tests/Unit/Console/Commands/OpenApiCommandTest.php @@ -6,6 +6,7 @@ use Quantum\Console\Commands\OpenApiCommand; use Quantum\Tests\Unit\AppTestCase; use Quantum\Storage\FileSystem; +use ReflectionMethod; class OpenApiCommandTest extends AppTestCase { @@ -78,4 +79,86 @@ public function testExecShowsErrorWhenModuleIsMissing(): void } } } + + public function testOpenapiRoutesContainsModuleSpecPath(): void + { + $method = new ReflectionMethod($this->command, 'openapiRoutes'); + $method->setAccessible(true); + + $routes = $method->invoke($this->command, 'Blog'); + + $this->assertStringContainsString('"openapi"', $routes); + $this->assertStringContainsString('Blog', $routes); + $this->assertTrue( + strpos($routes, 'resources/openapi/spec.json') !== false + || strpos($routes, 'resources\\openapi\\spec.json') !== false + ); + } + + public function testCopyResourcesSkipsExcludedFiles(): void + { + $sourceDir = base_dir() . DS . 'var' . DS . 'tmp_openapi_src_' . uniqid(); + $targetDir = base_dir() . DS . 'var' . DS . 'tmp_openapi_dst_' . uniqid(); + + mkdir($sourceDir, 0777, true); + mkdir($targetDir, 0777, true); + + file_put_contents($sourceDir . DS . 'swagger-ui.css', 'body{}'); + file_put_contents($sourceDir . DS . 'index.html', ''); + + $this->setPrivateProperty($this->command, 'vendorOpenApiFolderPath', $sourceDir); + $this->setPrivateProperty($this->command, 'publicOpenApiFolderPath', $targetDir); + + $method = new ReflectionMethod($this->command, 'copyResources'); + $method->setAccessible(true); + $method->invoke($this->command); + + $this->assertFileExists($targetDir . DS . 'swagger-ui.css'); + $this->assertFileDoesNotExist($targetDir . DS . 'index.html'); + + @unlink($targetDir . DS . 'swagger-ui.css'); + @rmdir($targetDir); + @unlink($sourceDir . DS . 'swagger-ui.css'); + @unlink($sourceDir . DS . 'index.html'); + @rmdir($sourceDir); + } + + public function testGenerateOpenapiSpecificationCreatesSpecFile(): void + { + $command = new class () extends OpenApiCommand { + public function info(string $message): void + { + } + + public function error(string $message): void + { + } + }; + + $moduleName = 'OpenApiSpec' . uniqid(); + $annotationDir = modules_dir() . DS . $moduleName . DS . 'Controllers' . DS . 'OpenApi'; + $specDir = modules_dir() . DS . $moduleName . DS . 'resources' . DS . 'openapi'; + $specPath = $specDir . DS . 'spec.json'; + + mkdir($annotationDir, 0777, true); + mkdir($specDir, 0777, true); + file_put_contents($annotationDir . DS . 'SpecController.php', "setAccessible(true); + $method->invoke($command, $moduleName); + + $this->assertFileExists($specPath); + $this->assertNotSame('', trim((string) file_get_contents($specPath))); + } finally { + @unlink($annotationDir . DS . 'SpecController.php'); + @rmdir($annotationDir); + @unlink($specPath); + @rmdir($specDir); + @rmdir(modules_dir() . DS . $moduleName . DS . 'Controllers'); + @rmdir(modules_dir() . DS . $moduleName . DS . 'resources'); + @rmdir(modules_dir() . DS . $moduleName); + } + } } diff --git a/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php b/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php index 1f672a2da..8fa6a4c3e 100644 --- a/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php +++ b/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php @@ -4,8 +4,9 @@ use Quantum\Console\Commands\ResourceCacheClearCommand; use Symfony\Component\Console\Tester\CommandTester; -use Quantum\Storage\FileSystem; use Quantum\Tests\Unit\AppTestCase; +use Quantum\Storage\FileSystem; +use ReflectionMethod; class ResourceCacheClearCommandTest extends AppTestCase { @@ -59,4 +60,72 @@ public function testExecShowsErrorWhenNoOptionsProvided(): void @rmdir($cacheDir); } + + public function testInitTypeAcceptsKnownType(): void + { + $method = new ReflectionMethod($this->command, 'initType'); + $method->setAccessible(true); + $method->invoke($this->command, 'views'); + + $this->assertSame('views', $this->getPrivateProperty($this->command, 'type')); + } + + public function testInitTypeThrowsForInvalidType(): void + { + $method = new ReflectionMethod($this->command, 'initType'); + $method->setAccessible(true); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Cache type {invalid} is invalid.'); + $method->invoke($this->command, 'invalid'); + } + + public function testInitModuleAcceptsKnownModule(): void + { + $this->setPrivateProperty($this->command, 'modules', ['blog', 'shop']); + + $method = new ReflectionMethod($this->command, 'initModule'); + $method->setAccessible(true); + $method->invoke($this->command, 'BLOG'); + + $this->assertSame('blog', $this->getPrivateProperty($this->command, 'module')); + } + + public function testClearResourceModuleAndTypeRemovesMatchedFiles(): void + { + config()->set('view_cache', ['cache_dir' => 'cache']); + + $cacheDir = base_dir() . DS . 'var' . DS . 'tmp_cache_' . uniqid(); + $moduleDir = $cacheDir . DS . 'views' . DS . 'blog'; + mkdir($moduleDir, 0777, true); + file_put_contents($moduleDir . DS . 'view.php', 'cached'); + + $this->setPrivateProperty($this->command, 'cacheDir', $cacheDir); + + $method = new ReflectionMethod($this->command, 'clearResourceModuleAndType'); + $method->setAccessible(true); + $method->invoke($this->command, 'blog', 'views'); + + $this->assertFileDoesNotExist($moduleDir . DS . 'view.php'); + + @rmdir($moduleDir); + @rmdir($cacheDir . DS . 'views'); + @rmdir($cacheDir); + } + + public function testExecClearsAllCacheWhenAllOptionProvided(): void + { + config()->set('view_cache', ['cache_dir' => 'tmp_cache_' . uniqid()]); + + $cacheDir = base_dir() . DS . config()->get('view_cache.cache_dir'); + mkdir($cacheDir, 0777, true); + file_put_contents($cacheDir . DS . 'a.cache', 'value'); + + $this->tester->execute(['--all' => true]); + + $this->assertFileDoesNotExist($cacheDir . DS . 'a.cache'); + $this->assertStringContainsString('Resource cache cleared successfully.', $this->tester->getDisplay()); + + @rmdir($cacheDir); + } } diff --git a/tests/Unit/Console/Commands/ServeCommandTest.php b/tests/Unit/Console/Commands/ServeCommandTest.php index 2820248be..c9a3100a4 100644 --- a/tests/Unit/Console/Commands/ServeCommandTest.php +++ b/tests/Unit/Console/Commands/ServeCommandTest.php @@ -5,6 +5,7 @@ use Symfony\Component\Console\Tester\CommandTester; use Quantum\Console\Commands\ServeCommand; use Quantum\Tests\Unit\AppTestCase; +use RuntimeException; class ServeCommandTest extends AppTestCase { @@ -85,4 +86,261 @@ protected function handleServerExecution(array $serverData): void $this->assertSame(8011, $command->receivedPort); $this->assertSame('http://127.0.0.1:8011', $command->receivedServerData['url']); } + + public function testPortThrowsForInvalidRange(): void + { + $command = new ServeCommand(); + + $tester = new CommandTester($command); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Port must be between 1 and 65535'); + $tester->execute(['--port' => '70000']); + } + + public function testStartServerOnAvailablePortSkipsBusyPort(): void + { + $command = new class () extends ServeCommand { + public int $cleanupCalls = 0; + protected int $maxPortScan = 3; + /** @var array */ + public array $checkedPorts = []; + + public function info(string $message): void + { + } + + protected function isPortInUse(string $host, int $port): bool + { + $this->checkedPorts[] = $port; + return $port === 8000; + } + + protected function startPhpServer(string $host, int $port) + { + return fopen('php://memory', 'r'); + } + + protected function waitUntilServerIsReady(string $host, int $port, $process): void + { + } + + protected function cleanupProcess($process): void + { + $this->cleanupCalls++; + } + + public function exposeStartServerOnAvailablePort(string $host, int $port): array + { + return $this->startServerOnAvailablePort($host, $port); + } + }; + + $serverData = $command->exposeStartServerOnAvailablePort('127.0.0.1', 8000); + + $this->assertSame([8000, 8001], $command->checkedPorts); + $this->assertSame(8001, $serverData['port']); + $this->assertSame('http://127.0.0.1:8001', $serverData['url']); + $this->assertSame(0, $command->cleanupCalls); + } + + public function testStartServerOnAvailablePortCleansUpWhenServerReadinessFails(): void + { + $command = new class () extends ServeCommand { + public int $cleanupCalls = 0; + protected int $maxPortScan = 2; + /** @var array */ + private array $attemptedPorts = []; + + public function info(string $message): void + { + } + + protected function isPortInUse(string $host, int $port): bool + { + return false; + } + + protected function startPhpServer(string $host, int $port) + { + $this->attemptedPorts[] = $port; + return fopen('php://memory', 'r'); + } + + protected function waitUntilServerIsReady(string $host, int $port, $process): void + { + throw new RuntimeException('not ready'); + } + + protected function cleanupProcess($process): void + { + $this->cleanupCalls++; + } + + public function exposeStartServerOnAvailablePort(string $host, int $port): array + { + return $this->startServerOnAvailablePort($host, $port); + } + }; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to start PHP server on any available port.'); + + try { + $command->exposeStartServerOnAvailablePort('127.0.0.1', 8000); + } finally { + $this->assertSame(2, $command->cleanupCalls); + } + } + + public function testHandleServerExecutionOpensBrowserWhenOptionEnabled(): void + { + $command = new class () extends ServeCommand { + public bool $openCalled = false; + public bool $waitCalled = false; + + protected function startServerOnAvailablePort(string $host, int $startPort): array + { + return [ + 'process' => fopen('php://memory', 'r'), + 'port' => $startPort, + 'url' => "http://{$host}:{$startPort}", + ]; + } + + protected function openBrowser(string $url): void + { + $this->openCalled = true; + } + + protected function waitForProcess($process): void + { + $this->waitCalled = true; + } + }; + + $tester = new CommandTester($command); + $tester->execute(['--open' => true]); + + $this->assertTrue($command->openCalled); + $this->assertTrue($command->waitCalled); + } + + public function testIsPortInUseDetectsListeningSocket(): void + { + $command = new class () extends ServeCommand { + public function exposeIsPortInUse(string $host, int $port): bool + { + return $this->isPortInUse($host, $port); + } + }; + + $server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); + $this->assertIsResource($server); + $name = stream_socket_get_name($server, false); + $port = (int) substr((string) $name, strrpos((string) $name, ':') + 1); + + try { + $this->assertTrue($command->exposeIsPortInUse('127.0.0.1', $port)); + } finally { + fclose($server); + } + + $this->assertFalse($command->exposeIsPortInUse('127.0.0.1', $port)); + } + + public function testWaitForProcessClosesCompletedProcess(): void + { + $command = new class () extends ServeCommand { + public function exposeWaitForProcess($process): void + { + $this->waitForProcess($process); + } + }; + + $process = proc_open([PHP_BINARY, '-r', 'usleep(100000);'], [0 => STDIN, 1 => STDOUT, 2 => STDERR], $pipes); + $this->assertIsResource($process); + + $command->exposeWaitForProcess($process); + $this->assertFalse(is_resource($process)); + } + + public function testStartPhpServerAndWaitUntilReady(): void + { + $command = new class () extends ServeCommand { + public function exposeStartPhpServer(string $host, int $port) + { + return $this->startPhpServer($host, $port); + } + + public function exposeWaitUntilServerIsReady(string $host, int $port, $process): void + { + $this->waitUntilServerIsReady($host, $port, $process); + } + + public function exposeCleanupProcess($process): void + { + $this->cleanupProcess($process); + } + + protected function serverProcessDescriptors(): array + { + return [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + } + }; + + $tempRoot = base_dir() . DS . 'var' . DS . 'tmp_serve_' . uniqid('', true); + $publicDir = $tempRoot . DS . 'public'; + $indexFile = $publicDir . DS . 'index.php'; + $originalCwd = getcwd(); + + mkdir($publicDir, 0777, true); + file_put_contents($indexFile, "assertTrue(chdir($tempRoot)); + $process = $command->exposeStartPhpServer('127.0.0.1', $port); + $this->assertIsResource($process); + $command->exposeWaitUntilServerIsReady('127.0.0.1', $port, $process); + $this->assertTrue(true); + } finally { + if (isset($process) && is_resource($process)) { + proc_terminate($process); + } + if (isset($process)) { + $command->exposeCleanupProcess($process); + } + chdir($originalCwd); + @unlink($indexFile); + @rmdir($publicDir); + @rmdir($tempRoot); + } + } + + public function testOpenBrowserReturnsEarlyWhenCommandIsNotSupported(): void + { + $command = new class () extends ServeCommand { + protected function browserCommand(string $url): ?array + { + return null; + } + + public function exposeOpenBrowser(string $url): void + { + $this->openBrowser($url); + } + }; + + $command->exposeOpenBrowser('http://127.0.0.1:8000'); + $this->assertTrue(true); + } }