diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md new file mode 100644 index 0000000..59a33e4 --- /dev/null +++ b/.gemini/GEMINI.md @@ -0,0 +1,27 @@ +# FlightPHP/Core Project Instructions + +## Overview +This is the main FlightPHP core library for building fast, simple, and extensible PHP web applications. It is dependency-free for core usage and supports PHP 7.4+. + +## Project Guidelines +- PHP 7.4 must be supported. PHP 8 or greater also supported, but avoid PHP 8+ only features. +- Keep the core library dependency-free (no polyfills or interface-only repositories). +- All Flight projects are meant to be kept simple and fast. Performance is a priority. +- Flight is extensible and when implementing new features, consider how they can be added as plugins or extensions rather than bloating the core library. +- Any new features built into the core should be well-documented and tested. +- Any new features should be added with a focus on simplicity and performance, avoiding unnecessary complexity. +- This is not a Laravel, Yii, Code Igniter or Symfony clone. It is a simple, fast, and extensible framework that allows you to build applications quickly without the overhead of large frameworks. + +## Development & Testing +- Run tests: `composer test` (uses phpunit/phpunit and spatie/phpunit-watcher) +- Run test server: `composer test-server` or `composer test-server-v2` +- Lint code: `composer lint` (uses phpstan/phpstan, level 6) +- Beautify code: `composer beautify` (uses squizlabs/php_codesniffer, PSR1) +- Check code style: `composer phpcs` +- Test coverage: `composer test-coverage` + +## Coding Standards +- Follow PSR1 coding standards (enforced by PHPCS) +- Use strict comparisons (`===`, `!==`) +- PHPStan level 6 compliance +- Focus on PHP 7.4 compatibility (avoid PHP 8+ only features) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2a6ec38..4e5513e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - php: [7.4, 8.0, 8.1, 8.2, 8.3, 8.4] + php: [7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5] runs-on: ubuntu-latest steps: - name: Checkout repository @@ -20,4 +20,4 @@ jobs: extensions: curl, mbstring tools: composer:v2 - run: composer install - - run: composer test-ci \ No newline at end of file + - run: composer test-ci diff --git a/.gitignore b/.gitignore index 90cd18f..49ff583 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ clover.xml phpcs.xml .runway-config.json .runway-creds.json +.DS_Store diff --git a/composer.json b/composer.json index 96d45db..ae0925e 100644 --- a/composer.json +++ b/composer.json @@ -44,14 +44,13 @@ "require-dev": { "ext-pdo_sqlite": "*", "flightphp/container": "^1.0", - "flightphp/runway": "^0.2.3 || ^1.0", + "flightphp/runway": "^1.2", "league/container": "^4.2", "level-2/dice": "^4.0", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^9.6", "rregeer/phpunit-coverage-check": "^0.3.1", - "spatie/phpunit-watcher": "^1.23 || ^1.24", "squizlabs/php_codesniffer": "^3.11" }, "config": { @@ -62,21 +61,27 @@ "sort-packages": true }, "scripts": { - "test": "vendor/bin/phpunit-watcher watch", - "test-ci": "phpunit", - "test-coverage": "rm -f clover.xml && XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage --coverage-clover=clover.xml && vendor/bin/coverage-check clover.xml 100", - "test-server": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server/", - "test-server-v2": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server-v2/", + "test": "@php vendor/bin/phpunit", + "test-watcher": "@php vendor/bin/phpunit-watcher watch", + "test-ci": "@php vendor/bin/phpunit", + "test-coverage": [ + "rm -f clover.xml", + "@putenv XDEBUG_MODE=coverage", + "@php vendor/bin/phpunit --coverage-html=coverage --coverage-clover=clover.xml", + "@php vendor/bin/coverage-check clover.xml 100" + ], + "test-server": "echo \"Running Test Server\" && @php -S localhost:8000 -t tests/server/", + "test-server-v2": "echo \"Running Test Server\" && @php -S localhost:8000 -t tests/server-v2/", "test-coverage:win": "del clover.xml && phpunit --coverage-html=coverage --coverage-clover=clover.xml && coverage-check clover.xml 100", - "test-performance": [ - "echo \"Running Performance Tests...\"", - "php -S localhost:8077 -t tests/performance/ > /dev/null 2>&1 & echo $! > server.pid", - "sleep 2", - "bash tests/performance/performance_tests.sh", - "kill `cat server.pid`", - "rm server.pid", - "echo \"Performance Tests Completed.\"" - ], + "test-performance": [ + "echo \"Running Performance Tests...\"", + "@php -S localhost:8077 -t tests/performance/ > /dev/null 2>&1 & echo $! > server.pid", + "sleep 2", + "bash tests/performance/performance_tests.sh", + "kill `cat server.pid`", + "rm server.pid", + "echo \"Performance Tests Completed.\"" + ], "lint": "phpstan --no-progress --memory-limit=256M -cphpstan.neon", "beautify": "phpcbf --standard=phpcs.xml", "phpcs": "phpcs --standard=phpcs.xml -n", @@ -92,4 +97,4 @@ "replace": { "mikecao/flight": "2.0.2" } -} +} \ No newline at end of file diff --git a/flight/Engine.php b/flight/Engine.php index 0cfaf68..fe88301 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -621,7 +621,7 @@ public function _start(): void $response->write(ob_get_clean()); } - // Run any before middlewares + // Run any after middlewares if (count($route->middleware) > 0) { // process the middleware in reverse order now $atLeastOneMiddlewareFailed = $this->processMiddleware($route, 'after'); diff --git a/flight/commands/AiGenerateInstructionsCommand.php b/flight/commands/AiGenerateInstructionsCommand.php index 90dd7cb..5190c3c 100644 --- a/flight/commands/AiGenerateInstructionsCommand.php +++ b/flight/commands/AiGenerateInstructionsCommand.php @@ -4,24 +4,23 @@ namespace flight\commands; -use Ahc\Cli\Input\Command; - /** - * @property-read ?string $credsFile + * @property-read ?string $configFile * @property-read ?string $baseDir */ -class AiGenerateInstructionsCommand extends Command +class AiGenerateInstructionsCommand extends AbstractBaseCommand { /** * Constructor for the AiGenerateInstructionsCommand class. * * Initializes a new instance of the command. + * + * @param array $config Config from config.php */ - public function __construct() + public function __construct(array $config) { - parent::__construct('ai:generate-instructions', 'Generate project-specific AI coding instructions'); - $this->option('--creds-file', 'Path to .runway-creds.json file', null, ''); - $this->option('--base-dir', 'Project base directory (for testing or custom use)', null, ''); + parent::__construct('ai:generate-instructions', 'Generate project-specific AI coding instructions', $config); + $this->option('--config-file', 'Path to .runway-config.json file (deprecated, use config.php instead)', null, ''); } /** @@ -34,15 +33,22 @@ public function __construct() * * @return int */ - public function execute() + public function execute(): int { $io = $this->app()->io(); - $baseDir = $this->baseDir ? rtrim($this->baseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR : getcwd() . DIRECTORY_SEPARATOR; - $runwayCredsFile = $this->credsFile ?: $baseDir . '.runway-creds.json'; - // Check for runway creds - if (!file_exists($runwayCredsFile)) { - $io->error('Missing .runway-creds.json. Please run the \'ai:init\' command first.', true); + if (empty($this->config['runway'])) { + $configFile = $this->configFile; + $io = $this->app()->io(); + $io->warn('The --config-file option is deprecated. Move your config values to the \'runway\' key in the config.php file for configuration.', true); + $runwayConfig = json_decode(file_get_contents($configFile), true) ?? []; + } else { + $runwayConfig = $this->config['runway']; + } + + // Check for runway creds ai + if (empty($runwayConfig['ai'])) { + $io->error('Missing AI configuration. Please run the \'ai:init\' command first.', true); return 1; } @@ -61,8 +67,8 @@ public function execute() $other = $io->prompt('Any other important requirements or context? (optional)', 'no'); // Prepare prompt for LLM - $contextFile = $baseDir . '.github/copilot-instructions.md'; - $context = file_exists($contextFile) ? file_get_contents($contextFile) : ''; + $contextFile = $this->projectRoot . '.github/copilot-instructions.md'; + $context = file_exists($contextFile) === true ? file_get_contents($contextFile) : ''; $userDetails = [ 'Project Description' => $projectDesc, 'Database' => $database, @@ -80,7 +86,7 @@ public function execute() $detailsText .= "$k: $v\n"; } $prompt = <<info('Updating .github/copilot-instructions.md, .cursor/rules/project-overview.mdc, and .windsurfrules...', true); - if (!is_dir($baseDir . '.github')) { - mkdir($baseDir . '.github', 0755, true); + $io->info('Updating .github/copilot-instructions.md, .cursor/rules/project-overview.mdc, .gemini/GEMINI.md and .windsurfrules...', true); + if (!is_dir($this->projectRoot . '.github')) { + mkdir($this->projectRoot . '.github', 0755, true); + } + if (!is_dir($this->projectRoot . '.cursor/rules')) { + mkdir($this->projectRoot . '.cursor/rules', 0755, true); } - if (!is_dir($baseDir . '.cursor/rules')) { - mkdir($baseDir . '.cursor/rules', 0755, true); + if (!is_dir($this->projectRoot . '.gemini')) { + mkdir($this->projectRoot . '.gemini', 0755, true); } - file_put_contents($baseDir . '.github/copilot-instructions.md', $instructions); - file_put_contents($baseDir . '.cursor/rules/project-overview.mdc', $instructions); - file_put_contents($baseDir . '.windsurfrules', $instructions); + file_put_contents($this->projectRoot . '.github/copilot-instructions.md', $instructions); + file_put_contents($this->projectRoot . '.cursor/rules/project-overview.mdc', $instructions); + file_put_contents($this->projectRoot . '.gemini/GEMINI.md', $instructions); + file_put_contents($this->projectRoot . '.windsurfrules', $instructions); $io->ok('AI instructions updated successfully.', true); return 0; } diff --git a/flight/commands/AiInitCommand.php b/flight/commands/AiInitCommand.php index f60ce95..92b468d 100644 --- a/flight/commands/AiInitCommand.php +++ b/flight/commands/AiInitCommand.php @@ -4,25 +4,22 @@ namespace flight\commands; -use Ahc\Cli\Input\Command; - /** - * @property-read ?string $gitignoreFile - * @property-read ?string $credsFile + * @property-read ?string $credsFile Deprecated, use config.php instead */ -class AiInitCommand extends Command +class AiInitCommand extends AbstractBaseCommand { /** * Constructor for the AiInitCommand class. * * Initializes the command instance and sets up any required dependencies. + * + * @param array $config Config from config.php */ - public function __construct() + public function __construct(array $config) { - parent::__construct('ai:init', 'Initialize LLM API credentials and settings'); - $this - ->option('--gitignore-file', 'Path to .gitignore file', null, '') - ->option('--creds-file', 'Path to .runway-creds.json file', null, ''); + parent::__construct('ai:init', 'Initialize LLM API credentials and settings', $config); + $this->option('--creds-file', 'Path to .runway-creds.json file (deprecated, use config.php instead)', null, ''); } /** @@ -30,27 +27,12 @@ public function __construct() * * @return int */ - public function execute() + public function execute(): int { $io = $this->app()->io(); $io->info('Welcome to AI Init!', true); - $baseDir = getcwd() . DIRECTORY_SEPARATOR; - $runwayCredsFile = $this->credsFile ?: $baseDir . '.runway-creds.json'; - $gitignoreFile = $this->gitignoreFile ?: $baseDir . '.gitignore'; - - // make sure the .runway-creds.json file is not already present - if (file_exists($runwayCredsFile)) { - $io->error('.runway-creds.json file already exists. Please remove it before running this command.', true); - // prompt to overwrite - $overwrite = $io->confirm('Do you want to overwrite the existing .runway-creds.json file?', 'n'); - if ($overwrite === false) { - $io->info('Exiting without changes.', true); - return 0; - } - } - // Prompt for API provider with validation $allowedApis = [ '1' => 'openai', @@ -88,50 +70,26 @@ public function execute() // Validate model input switch ($api) { case 'openai': - $defaultModel = 'gpt-4o'; + $defaultModel = 'gpt-5'; break; case 'grok': - $defaultModel = 'grok-3-beta'; + $defaultModel = 'grok-4.1-fast-non-reasoning'; break; case 'claude': - $defaultModel = 'claude-3-opus'; + $defaultModel = 'claude-sonnet-4-5'; break; } - $model = trim($io->prompt('Enter the model name you want to use (e.g. gpt-4, claude-3-opus, etc)', $defaultModel)); + $model = trim($io->prompt('Enter the model name you want to use (e.g. gpt-5, claude-sonnet-4-5, etc)', $defaultModel)); - $creds = [ + $runwayAiConfig = [ 'provider' => $api, 'api_key' => $apiKey, 'model' => $model, 'base_url' => $baseUrl, ]; + $this->setRunwayConfigValue('ai', $runwayAiConfig); - $json = json_encode($creds, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $file = $runwayCredsFile; - file_put_contents($file, $json); - - // change permissions to 600 - chmod($file, 0600); - - $io->ok('Credentials saved to ' . $file, true); - - // run a check to make sure that the creds file is in the .gitignore file - // use $gitignoreFile instead of hardcoded path - if (!file_exists($gitignoreFile)) { - // create the .gitignore file if it doesn't exist - file_put_contents($gitignoreFile, basename($runwayCredsFile) . "\n"); - $io->info(basename($gitignoreFile) . ' file created and ' . basename($runwayCredsFile) . ' added to it.', true); - } else { - // check if the creds file is already in the .gitignore file - $gitignoreContents = file_get_contents($gitignoreFile); - if (strpos($gitignoreContents, basename($runwayCredsFile)) === false) { - // add the creds file to the .gitignore file - file_put_contents($gitignoreFile, "\n" . basename($runwayCredsFile) . "\n", FILE_APPEND); - $io->info(basename($runwayCredsFile) . ' added to ' . basename($gitignoreFile) . ' file.', true); - } else { - $io->info(basename($runwayCredsFile) . ' is already in the ' . basename($gitignoreFile) . ' file.', true); - } - } + $io->ok('Credentials saved to app/config/config.php', true); return 0; } diff --git a/flight/commands/ControllerCommand.php b/flight/commands/ControllerCommand.php index 9706d97..e61dada 100644 --- a/flight/commands/ControllerCommand.php +++ b/flight/commands/ControllerCommand.php @@ -26,11 +26,19 @@ public function __construct(array $config) * * @return void */ - public function execute(string $controller) + public function execute(string $controller): void { $io = $this->app()->io(); - if (isset($this->config['app_root']) === false) { - $io->error('app_root not set in .runway-config.json', true); + + if (empty($this->config['runway'])) { + $io->warn('Using a .runway-config.json file is deprecated. Move your config values to app/config/config.php with `php runway config:migrate`.', true); // @codeCoverageIgnore + $runwayConfig = json_decode(file_get_contents($this->projectRoot . '/.runway-config.json'), true); // @codeCoverageIgnore + } else { + $runwayConfig = $this->config['runway']; + } + + if (isset($runwayConfig['app_root']) === false) { + $io->error('app_root not set in app/config/config.php', true); return; } @@ -38,7 +46,7 @@ public function execute(string $controller) $controller .= 'Controller'; } - $controllerPath = getcwd() . DIRECTORY_SEPARATOR . $this->config['app_root'] . 'controllers' . DIRECTORY_SEPARATOR . $controller . '.php'; + $controllerPath = $this->projectRoot . '/' . $runwayConfig['app_root'] . 'controllers/' . $controller . '.php'; if (file_exists($controllerPath) === true) { $io->error($controller . ' already exists.', true); return; @@ -70,7 +78,7 @@ public function execute(string $controller) $namespace->add($class); $file->addNamespace($namespace); - $this->persistClass($controller, $file); + $this->persistClass($controller, $file, $runwayConfig['app_root']); $io->ok('Controller successfully created at ' . $controllerPath, true); } @@ -80,12 +88,13 @@ public function execute(string $controller) * * @param string $controllerName Name of the Controller * @param PhpFile $file Class Object from Nette\PhpGenerator + * @param string $appRoot App Root from runway config * * @return void */ - protected function persistClass(string $controllerName, PhpFile $file) + protected function persistClass(string $controllerName, PhpFile $file, string $appRoot) { $printer = new \Nette\PhpGenerator\PsrPrinter(); - file_put_contents(getcwd() . DIRECTORY_SEPARATOR . $this->config['app_root'] . 'controllers' . DIRECTORY_SEPARATOR . $controllerName . '.php', $printer->printFile($file)); + file_put_contents($this->projectRoot . '/' . $appRoot . 'controllers/' . $controllerName . '.php', $printer->printFile($file)); } } diff --git a/flight/commands/RouteCommand.php b/flight/commands/RouteCommand.php index 8b5408e..1667934 100644 --- a/flight/commands/RouteCommand.php +++ b/flight/commands/RouteCommand.php @@ -37,20 +37,25 @@ public function __construct(array $config) * * @return void */ - public function execute() + public function execute(): void { $io = $this->app()->io(); - if (isset($this->config['index_root']) === false) { - $io->error('index_root not set in .runway-config.json', true); + if (empty($this->config['runway'])) { + $io->warn('Using a .runway-config.json file is deprecated. Move your config values to app/config/config.php with `php runway config:migrate`.', true); // @codeCoverageIgnore + $runwayConfig = json_decode(file_get_contents($this->projectRoot . '/.runway-config.json'), true); // @codeCoverageIgnore + } else { + $runwayConfig = $this->config['runway']; + } + + if (isset($runwayConfig['index_root']) === false) { + $io->error('index_root not set in app/config/config.php', true); return; } $io->bold('Routes', true); - $cwd = getcwd(); - - $index_root = $cwd . '/' . $this->config['index_root']; + $index_root = $this->projectRoot . '/' . $runwayConfig['index_root']; // This makes it so the framework doesn't actually execute Flight::map('start', function () { @@ -65,11 +70,15 @@ public function execute() if (!empty($route->middleware)) { try { $middlewares = array_map(function ($middleware) { - $middleware_class_name = explode("\\", get_class($middleware)); + if (is_string($middleware)) { + $middleware_class_name = explode("\\", $middleware); + } else { + $middleware_class_name = explode("\\", get_class($middleware)); + } return preg_match("/^class@anonymous/", end($middleware_class_name)) ? 'Anonymous' : end($middleware_class_name); }, $route->middleware); - } catch (\TypeError $e) { - $middlewares[] = 'Bad Middleware'; + } catch (\TypeError $e) { // @codeCoverageIgnore + $middlewares[] = 'Bad Middleware'; // @codeCoverageIgnore } finally { if (is_string($route->middleware) === true) { $middlewares[] = $route->middleware; diff --git a/flight/database/PdoWrapper.php b/flight/database/PdoWrapper.php index ff13a4c..164946c 100644 --- a/flight/database/PdoWrapper.php +++ b/flight/database/PdoWrapper.php @@ -9,6 +9,9 @@ use PDO; use PDOStatement; +/** + * @deprecated version 3.18.0 - Use SimplePdo instead + */ class PdoWrapper extends PDO { /** @var bool $trackApmQueries Whether to track application performance metrics (APM) for queries. */ diff --git a/flight/database/SimplePdo.php b/flight/database/SimplePdo.php new file mode 100644 index 0000000..c1bc0b7 --- /dev/null +++ b/flight/database/SimplePdo.php @@ -0,0 +1,433 @@ +|null $pdoOptions An array of options for the PDO connection. + * @param array $options An array of options for the SimplePdo class + */ + public function __construct( + ?string $dsn = null, + ?string $username = null, + ?string $password = null, + ?array $pdoOptions = null, + array $options = [] + ) { + // Set default fetch mode if not provided in pdoOptions + if (isset($pdoOptions[PDO::ATTR_DEFAULT_FETCH_MODE]) === false) { + $pdoOptions = $pdoOptions ?? []; + $pdoOptions[PDO::ATTR_DEFAULT_FETCH_MODE] = PDO::FETCH_ASSOC; + } + + // Pass to parent (PdoWrapper) constructor + parent::__construct($dsn, $username, $password, $pdoOptions, false); // APM off by default here + + // Modern defaults – override parent's behavior where needed + $defaults = [ + 'trackApmQueries' => false, // still optional + 'maxQueryMetrics' => 1000, + ]; + + $options = array_merge($defaults, $options); + + $this->trackApmQueries = (bool) $options['trackApmQueries']; + $this->maxQueryMetrics = (int) $options['maxQueryMetrics']; + + // If APM is enabled, pull connection metrics (same as parent) + if ($this->trackApmQueries && $dsn !== null) { + $this->connectionMetrics = $this->pullDataFromDsn($dsn); + } + } + + /** + * Pulls one row from the query + * + * Ex: $row = $db->fetchRow("SELECT * FROM table WHERE something = ?", [ $something ]); + * + * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" + * @param array $params - Ex: [ $something ] + * + * @return ?Collection + */ + public function fetchRow(string $sql, array $params = []): ?Collection + { + // Smart LIMIT 1 addition (avoid if already present at end or complex query) + if (!preg_match('/\s(LIMIT\s+\d+(?:\s+OFFSET\s+\d+)?|RETURNING\s+.+)\s*$/i', trim($sql))) { + $sql .= ' LIMIT 1'; + } + + $results = $this->fetchAll($sql, $params); + + return $results ? $results[0] : null; + } + + /** + * Don't worry about this guy. Converts stuff for IN statements + * + * Ex: $row = $db->fetchAll("SELECT * FROM table WHERE id = ? AND something IN(?), [ $id, [1,2,3] ]); + * Converts this to "SELECT * FROM table WHERE id = ? AND something IN(?,?,?)" + * + * @param string $sql the sql statement + * @param array $params the params for the sql statement + * + * @return array> + */ + protected function processInStatementSql(string $sql, array $params = []): array + { + // First, find all placeholders (?) in the original SQL and their positions + // We need to track which are IN(?) patterns vs regular ? + $originalSql = $sql; + $newParams = []; + $paramIndex = 0; + + // Find all ? positions and whether they're part of IN(?) + $pattern = '/IN\s*\(\s*\?\s*\)/i'; + $inPositions = []; + if (preg_match_all($pattern, $originalSql, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches[0] as $match) { + $inPositions[] = $match[1]; + } + } + + // Process from right to left so string positions don't shift + $inPositions = array_reverse($inPositions); + + // First, figure out which param indices correspond to IN(?) patterns + $questionMarkPositions = []; + $pos = 0; + while (($pos = strpos($originalSql, '?', $pos)) !== false) { + $questionMarkPositions[] = $pos; + $pos++; + } + + // Map each ? position to whether it's inside an IN() + $inParamIndices = []; + foreach ($inPositions as $inPos) { + // Find which ? is inside this IN() + foreach ($questionMarkPositions as $idx => $qPos) { + if ($qPos > $inPos && $qPos < $inPos + 20) { // IN(?) is typically under 20 chars + $inParamIndices[$idx] = true; + break; + } + } + } + + // Now build the new SQL and params + $newSql = $originalSql; + $offset = 0; + + // Process each param + for ($i = 0; $i < count($params); $i++) { + if (isset($inParamIndices[$i])) { + $value = $params[$i]; + + // Find the next IN(?) in the remaining SQL + if (preg_match($pattern, $newSql, $match, PREG_OFFSET_CAPTURE, $offset)) { + $matchPos = $match[0][1]; + $matchLen = strlen($match[0][0]); + + if (!is_array($value)) { + // Single value, keep as-is + $newParams[] = $value; + $newSql = substr_replace($newSql, 'IN(?)', $matchPos, $matchLen); + $offset = $matchPos + 5; + } elseif (count($value) === 0) { + // Empty array + $newSql = substr_replace($newSql, 'IN(NULL)', $matchPos, $matchLen); + $offset = $matchPos + 8; + } else { + // Expand array + $placeholders = implode(',', array_fill(0, count($value), '?')); + $replacement = "IN($placeholders)"; + $newSql = substr_replace($newSql, $replacement, $matchPos, $matchLen); + $newParams = array_merge($newParams, $value); + $offset = $matchPos + strlen($replacement); + } + } + } else { + $newParams[] = $params[$i]; + } + } + + return ['sql' => $newSql, 'params' => $newParams]; + } + + /** + * Use this for INSERTS, UPDATES, or if you plan on using a SELECT in a while loop + * + * Ex: $statement = $db->runQuery("SELECT * FROM table WHERE something = ?", [ $something ]); + * while($row = $statement->fetch()) { + * // ... + * } + * + * $db->runQuery("INSERT INTO table (name) VALUES (?)", [ $name ]); + * $db->runQuery("UPDATE table SET name = ? WHERE id = ?", [ $name, $id ]); + * + * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" + * @param array $params - Ex: [ $something ] + * + * @return PDOStatement + */ + public function runQuery(string $sql, array $params = []): PDOStatement + { + $processed = $this->processInStatementSql($sql, $params); + $sql = $processed['sql']; + $params = $processed['params']; + + $start = $this->trackApmQueries ? microtime(true) : 0; + $memoryStart = $this->trackApmQueries ? memory_get_usage() : 0; + + $stmt = $this->prepare($sql); + if ($stmt === false) { + throw new PDOException( + "Prepare failed: " . ($this->errorInfo()[2] ?? 'Unknown error') + ); + } + + $stmt->execute($params); + + if ($this->trackApmQueries) { + $this->queryMetrics[] = [ + 'sql' => $sql, + 'params' => $params, + 'execution_time' => microtime(true) - $start, + 'row_count' => $stmt->rowCount(), + 'memory_usage' => memory_get_usage() - $memoryStart + ]; + + // Cap to prevent memory leak in long-running processes + if (count($this->queryMetrics) > $this->maxQueryMetrics) { + array_shift($this->queryMetrics); + } + } + + return $stmt; + } + + /** + * Pulls all rows from the query + * + * Ex: $rows = $db->fetchAll("SELECT * FROM table WHERE something = ?", [ $something ]); + * foreach($rows as $row) { + * // ... + * } + * + * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" + * @param array $params - Ex: [ $something ] + * + * @return array> + */ + public function fetchAll(string $sql, array $params = []): array + { + $stmt = $this->runQuery($sql, $params); // Already processes IN statements and tracks metrics + $results = $stmt->fetchAll(PDO::FETCH_ASSOC); + return array_map(fn($row) => new Collection($row), $results); + } + + /** + * Fetch a single column as an array + * + * Ex: $ids = $db->fetchColumn("SELECT id FROM users WHERE active = ?", [1]); + * + * @param string $sql + * @param array $params + * + * @return array + */ + public function fetchColumn(string $sql, array $params = []): array + { + $stmt = $this->runQuery($sql, $params); + return $stmt->fetchAll(PDO::FETCH_COLUMN, 0); + } + + /** + * Fetch results as key-value pairs (first column as key, second as value) + * + * Ex: $userNames = $db->fetchPairs("SELECT id, name FROM users"); + * + * @param string $sql + * @param array $params + * + * @return array + */ + public function fetchPairs(string $sql, array $params = []): array + { + $stmt = $this->runQuery($sql, $params); + return $stmt->fetchAll(PDO::FETCH_KEY_PAIR); + } + + /** + * Execute a callback within a transaction + * + * Ex: $db->transaction(function($db) { + * $db->runQuery("INSERT INTO users (name) VALUES (?)", ['John']); + * $db->runQuery("INSERT INTO logs (action) VALUES (?)", ['user_created']); + * return $db->lastInsertId(); + * }); + * + * @param callable $callback + * + * @return mixed The return value of the callback + * + * @throws \Throwable + */ + public function transaction(callable $callback) + { + $this->beginTransaction(); + try { + $result = $callback($this); + $this->commit(); + return $result; + } catch (\Throwable $e) { + $this->rollBack(); + throw $e; + } + } + + /** + * Insert one or more rows and return the last insert ID + * + * Single insert: + * $id = $db->insert('users', ['name' => 'John', 'email' => 'john@example.com']); + * + * Bulk insert: + * $id = $db->insert('users', [ + * ['name' => 'John', 'email' => 'john@example.com'], + * ['name' => 'Jane', 'email' => 'jane@example.com'], + * ]); + * + * @param string $table + * @param array|array> $data Single row or array of rows + * + * @return string Last insert ID (for single insert or last row of bulk insert) + */ + public function insert(string $table, array $data): string + { + // Detect if this is a bulk insert (array of arrays) + $isBulk = isset($data[0]) && is_array($data[0]); + + if ($isBulk) { + // Bulk insert + if (empty($data[0])) { + throw new PDOException("Cannot perform bulk insert with empty data array"); + } + + // Use first row to determine columns + $firstRow = $data[0]; + $columns = array_keys($firstRow); + $columnCount = count($columns); + + // Validate all rows have same columns + foreach ($data as $index => $row) { + if (count($row) !== $columnCount) { + throw new PDOException( + "Row $index has " . count($row) . " columns, expected $columnCount" + ); + } + } + + // Build placeholders for multiple rows: (?,?), (?,?), (?,?) + $rowPlaceholder = '(' . implode(',', array_fill(0, $columnCount, '?')) . ')'; + $allPlaceholders = implode(', ', array_fill(0, count($data), $rowPlaceholder)); + + $sql = sprintf( + "INSERT INTO %s (%s) VALUES %s", + $table, + implode(', ', $columns), + $allPlaceholders + ); + + // Flatten all row values into a single params array + $params = []; + foreach ($data as $row) { + $params = array_merge($params, array_values($row)); + } + + $this->runQuery($sql, $params); + } else { + // Single insert + $columns = array_keys($data); + $placeholders = array_fill(0, count($data), '?'); + + $sql = sprintf( + "INSERT INTO %s (%s) VALUES (%s)", + $table, + implode(', ', $columns), + implode(', ', $placeholders) + ); + + $this->runQuery($sql, array_values($data)); + } + + return $this->lastInsertId(); + } + + /** + * Update rows and return the number of affected rows + * + * Ex: $affected = $db->update('users', ['name' => 'Jane'], 'id = ?', [1]); + * + * Note: SQLite's rowCount() returns the number of rows where data actually changed. + * If you UPDATE a row with the same values it already has, rowCount() will return 0. + * This differs from MySQL's behavior when using PDO::MYSQL_ATTR_FOUND_ROWS. + * + * @param string $table + * @param array $data + * @param string $where - e.g., "id = ?" + * @param array $whereParams + * + * @return int Number of affected rows (rows where data actually changed) + */ + public function update(string $table, array $data, string $where, array $whereParams = []): int + { + $sets = []; + foreach (array_keys($data) as $column) { + $sets[] = "$column = ?"; + } + + $sql = sprintf( + "UPDATE %s SET %s WHERE %s", + $table, + implode(', ', $sets), + $where + ); + + $params = array_merge(array_values($data), $whereParams); + $stmt = $this->runQuery($sql, $params); + return $stmt->rowCount(); + } + + /** + * Delete rows and return the number of deleted rows + * + * Ex: $deleted = $db->delete('users', 'id = ?', [1]); + * + * @param string $table + * @param string $where - e.g., "id = ?" + * @param array $whereParams + * + * @return int Number of deleted rows + */ + public function delete(string $table, string $where, array $whereParams = []): int + { + $sql = "DELETE FROM $table WHERE $where"; + $stmt = $this->runQuery($sql, $whereParams); + return $stmt->rowCount(); + } +} diff --git a/flight/net/Request.php b/flight/net/Request.php index c56f1f4..845b55c 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -147,6 +147,13 @@ class Request */ public string $body = ''; + /** + * Hold tmp file handles created via tmpfile() so they persist for request lifetime + * + * @var array + */ + private array $tmpFileHandles = []; + /** * Constructor. * @@ -166,25 +173,28 @@ public function __construct(array $config = []) $base = str_replace(['\\', ' '], ['/', '%20'], $base); } $base = dirname($base); + if ($base === '\\') { + $base = '/'; // @codeCoverageIgnore + } $config = [ - 'url' => $url, - 'base' => $base, - 'method' => $this->getMethod(), - 'referrer' => $this->getVar('HTTP_REFERER'), - 'ip' => $this->getVar('REMOTE_ADDR'), - 'ajax' => $this->getVar('HTTP_X_REQUESTED_WITH') === 'XMLHttpRequest', - 'scheme' => $scheme, + 'url' => $url, + 'base' => $base, + 'method' => $this->getMethod(), + 'referrer' => $this->getVar('HTTP_REFERER'), + 'ip' => $this->getVar('REMOTE_ADDR'), + 'ajax' => $this->getVar('HTTP_X_REQUESTED_WITH') === 'XMLHttpRequest', + 'scheme' => $scheme, 'user_agent' => $this->getVar('HTTP_USER_AGENT'), - 'type' => $this->getVar('CONTENT_TYPE'), - 'length' => intval($this->getVar('CONTENT_LENGTH', 0)), - 'query' => new Collection($_GET), - 'data' => new Collection($_POST), - 'cookies' => new Collection($_COOKIE), - 'files' => new Collection($_FILES), - 'secure' => $scheme === 'https', - 'accept' => $this->getVar('HTTP_ACCEPT'), - 'proxy_ip' => $this->getProxyIpAddress(), - 'host' => $this->getVar('HTTP_HOST'), + 'type' => $this->getVar('CONTENT_TYPE'), + 'length' => intval($this->getVar('CONTENT_LENGTH', 0)), + 'query' => new Collection($_GET), + 'data' => new Collection($_POST), + 'cookies' => new Collection($_COOKIE), + 'files' => new Collection($_FILES), + 'secure' => $scheme === 'https', + 'accept' => $this->getVar('HTTP_ACCEPT'), + 'proxy_ip' => $this->getProxyIpAddress(), + 'host' => $this->getVar('HTTP_HOST'), 'servername' => $this->getVar('SERVER_NAME', ''), ]; } @@ -233,14 +243,9 @@ public function init(array $properties = []): self $this->data->setData($data); } } - // Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data + // Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data or multipart/form-data } elseif (in_array($this->method, ['PUT', 'DELETE', 'PATCH'], true) === true) { - $body = $this->getBody(); - if ($body !== '') { - $data = []; - parse_str($body, $data); - $this->data->setData($data); - } + $this->parseRequestBodyForHttpMethods(); } return $this; @@ -370,7 +375,7 @@ public static function getHeaders(): array * * @return string */ - public static function header(string $header, $default = '') + public static function header(string $header, $default = ''): string { return self::getHeader($header, $default); } @@ -458,7 +463,7 @@ public static function getScheme(): string */ public function negotiateContentType(array $supported): ?string { - $accept = $this->header('Accept') ?? ''; + $accept = $this->header('Accept') ?: ''; if ($accept === '') { return $supported[0]; } @@ -473,13 +478,17 @@ public function negotiateContentType(array $supported): ?string /** * Retrieves the array of uploaded files. * - * @return array|array>> The array of uploaded files. + * @return array> Key is field name; value is either a single UploadedFile or an array of UploadedFile when multiple were uploaded. */ public function getUploadedFiles(): array { - $files = []; + $uploadedFiles = []; $correctedFilesArray = $this->reArrayFiles($this->files); foreach ($correctedFilesArray as $keyName => $files) { + // Check if original data was array format (files_name[] style) + $originalFile = $this->files->getData()[$keyName] ?? null; + $isArrayFormat = $originalFile && is_array($originalFile['name']); + foreach ($files as $file) { $UploadedFile = new UploadedFile( $file['name'], @@ -488,15 +497,17 @@ public function getUploadedFiles(): array $file['tmp_name'], $file['error'] ); - if (count($files) > 1) { - $files[$keyName][] = $UploadedFile; + + // Always use array format if original data was array, regardless of count + if ($isArrayFormat === true) { + $uploadedFiles[$keyName][] = $UploadedFile; } else { - $files[$keyName] = $UploadedFile; + $uploadedFiles[$keyName] = $UploadedFile; } } } - return $files; + return $uploadedFiles; } /** @@ -508,10 +519,9 @@ public function getUploadedFiles(): array */ protected function reArrayFiles(Collection $filesCollection): array { - $fileArray = []; foreach ($filesCollection as $fileKeyName => $file) { - $isMulti = is_array($file['name']) === true && count($file['name']) > 1; + $isMulti = is_array($file['name']) === true; $fileCount = $isMulti === true ? count($file['name']) : 1; $fileKeys = array_keys($file); @@ -528,4 +538,302 @@ protected function reArrayFiles(Collection $filesCollection): array return $fileArray; } + + /** + * Parse request body data for HTTP methods that don't natively support form data (PUT, DELETE, PATCH) + * + * @return void + */ + protected function parseRequestBodyForHttpMethods(): void + { + $body = $this->getBody(); + + // Empty body + if ($body === '') { + return; + } + + // Check Content-Type for multipart/form-data + $contentType = strtolower(trim($this->type)); + $isMultipart = strpos($contentType, 'multipart/form-data') === 0; + $boundary = null; + + if ($isMultipart === true) { + // Extract boundary more safely + if (preg_match('/boundary=(["\']?)([^"\';,\s]+)\1/i', $this->type, $matches)) { + $boundary = $matches[2]; + } + + // If no boundary found, it's not valid multipart + if (empty($boundary)) { + $isMultipart = false; + } + + $firstLine = strtok($body, "\r\n"); + if ($firstLine === false || strpos($firstLine, '--' . $boundary) !== 0) { + // Does not start with the boundary marker; fall back + $isMultipart = false; + } + } + + // Parse application/x-www-form-urlencoded + if ($isMultipart === false) { + parse_str($body, $data); + $this->data->setData($data); + return; + } + + $this->setParsedRequestBodyMultipartFormData($body, $boundary); + } + + /** + * Sets the parsed request body for multipart form data requests + * + * This method processes and stores multipart form data from the request body, + * parsing it according to the specified boundary delimiter. It handles the + * complex parsing of multipart data including file uploads and form fields. + * + * @param string $body The raw multipart request body content + * @param string $boundary The boundary string used to separate multipart sections + * + * @return void + */ + protected function setParsedRequestBodyMultipartFormData(string $body, string $boundary): void + { + + $data = []; + $file = []; + + // Parse multipart/form-data + $bodyParts = preg_split('/\R?-+' . preg_quote($boundary, '/') . '/s', $body); + array_pop($bodyParts); // Remove last element (closing boundary or empty) + + $partsProcessed = 0; + $filesTotalBytes = 0; + // Use ini values directly + $maxParts = (int) ini_get('max_file_uploads'); + if ($maxParts <= 0) { + // unlimited parts if not specified + $maxParts = PHP_INT_MAX; // @codeCoverageIgnore + } + $maxTotalBytes = self::derivePostMaxSizeBytes(); + + foreach ($bodyParts as $bodyPart) { + if ($partsProcessed >= $maxParts) { + // reached part limit from ini + break; // @codeCoverageIgnore + } + if ($bodyPart === '' || $bodyPart === null) { + continue; // skip empty segments + } + $partsProcessed++; + + // Split headers and value; if format invalid, skip early + $split = preg_split('/\R\R/', $bodyPart, 2); + if ($split === false || count($split) < 2) { + continue; + } + [$header, $value] = $split; + + // Fast header sanity checks + if (stripos($header, 'content-disposition') === false) { + continue; + } + if (strlen($header) > 16384) { // 16KB header block guard + continue; + } + + $value = ltrim($value, "\r\n"); + + // Parse headers (simple approach, fail-fast on anomalies) + $headers = $this->parseRequestBodyHeadersFromMultipartFormData($header); + + // Required disposition/name + if (isset($headers['content-disposition']['name']) === false) { + continue; + } + $keyName = str_replace('[]', '', (string) $headers['content-disposition']['name']); + if ($keyName === '') { + continue; // avoid empty keys + } + + // Non-file field + if (isset($headers['content-disposition']['filename']) === false) { + if (isset($data[$keyName]) === false) { + $data[$keyName] = $value; + } else { + if (is_array($data[$keyName]) === false) { + $data[$keyName] = [$data[$keyName]]; + } + $data[$keyName][] = $value; + } + continue; // done with this part + } + + // Sanitize filename early + $rawFilename = (string) $headers['content-disposition']['filename']; + $rawFilename = str_replace(["\0", "\r", "\n"], '', $rawFilename); + $sanitizedFilename = basename($rawFilename); + $matchCriteria = preg_match('/^[A-Za-z0-9._-]{1,255}$/', $sanitizedFilename); + if ($sanitizedFilename === '' || $matchCriteria !== 1) { + $sanitizedFilename = 'upload_' . uniqid('', true); + } + + $size = mb_strlen($value, '8bit'); + $filesTotalBytes += $size; + $tmpFile = [ + 'name' => $sanitizedFilename, + 'type' => $headers['content-type'] ?? 'application/octet-stream', + 'size' => $size, + 'tmp_name' => '', + 'error' => UPLOAD_ERR_OK, + ]; + + // Fail fast on size constraints + if ($size > $this->getUploadMaxFileSize() || $filesTotalBytes > $maxTotalBytes) { + // individual file or total size exceeded + $tmpFile['error'] = UPLOAD_ERR_INI_SIZE; // @codeCoverageIgnore + } else { + $tempResult = $this->createTempFile($value); + $tmpFile['tmp_name'] = $tempResult['tmp_name']; + $tmpFile['error'] = $tempResult['error']; + } + + // Aggregate into synthetic files array + foreach ($tmpFile as $metaKey => $metaVal) { + if (!isset($file[$keyName][$metaKey])) { + $file[$keyName][$metaKey] = $metaVal; + continue; + } + if (!is_array($file[$keyName][$metaKey])) { + $file[$keyName][$metaKey] = [$file[$keyName][$metaKey]]; + } + $file[$keyName][$metaKey][] = $metaVal; + } + } + + $this->data->setData($data); + $this->files->setData($file); + } + + /** + * Parses request body headers from multipart form data + * + * This method extracts and processes headers from a multipart form data section, + * typically used for file uploads or complex form submissions. It parses the + * header string and returns an associative array of header name-value pairs. + * + * @param string $header The raw header string from a multipart form data section + * + * @return array An associative array containing parsed header name-value pairs + */ + protected function parseRequestBodyHeadersFromMultipartFormData(string $header): array + { + $headers = []; + foreach (preg_split('/\R/', $header) as $headerLine) { + if (strpos($headerLine, ':') === false) { + continue; + } + [$headerKey, $headerValue] = explode(':', $headerLine, 2); + $headerKey = strtolower(trim($headerKey)); + $headerValue = trim($headerValue); + if (strpos($headerValue, ';') !== false) { + $headers[$headerKey] = []; + foreach (explode(';', $headerValue) as $hvPart) { + preg_match_all('/(\w+)="?([^";]+)"?/', $hvPart, $matches, PREG_SET_ORDER); + foreach ($matches as $m) { + $subKey = strtolower($m[1]); + $headers[$headerKey][$subKey] = $m[2]; + } + } + } else { + $headers[$headerKey] = $headerValue; + } + } + return $headers; + } + + /** + * Get the maximum file size that can be uploaded. + * + * @return int The maximum file size in bytes. + */ + public function getUploadMaxFileSize(): int + { + $value = ini_get('upload_max_filesize'); + return self::parsePhpSize($value); + } + + /** + * Parse a PHP shorthand size string (like "1K", "1.5M") into bytes. + * Returns 0 on unknown or unsupported unit (keeps existing behavior). + * + * @param string $size + * + * @return int + */ + public static function parsePhpSize(string $size): int + { + $unit = strtolower(preg_replace('/[^a-zA-Z]/', '', $size)); + $value = (int) preg_replace('/[^\d.]/', '', $size); + + // No unit => follow existing behavior and return value directly if > 1024 (1K) + if ($unit === '' && $value >= 1024) { + return $value; + } + + switch ($unit) { + case 't': + case 'tb': + $value *= 1024; // Fall through + case 'g': + case 'gb': + $value *= 1024; // Fall through + case 'm': + case 'mb': + $value *= 1024; // Fall through + case 'k': + $value *= 1024; + break; + default: + return 0; + } + + return $value; + } + + /** + * Derive post_max_size in bytes. Returns 0 when unlimited or unparsable. + */ + private static function derivePostMaxSizeBytes(): int + { + $postMax = (string) ini_get('post_max_size'); + $bytes = self::parsePhpSize($postMax); + return $bytes; // 0 means unlimited + } + + /** + * Create a temporary file for uploaded content using tmpfile(). + * Returns array with tmp_name and error code. + * + * @param string $content + * + * @return array + */ + private function createTempFile(string $content): array + { + $fp = tmpfile(); + if ($fp === false) { + return ['tmp_name' => '', 'error' => UPLOAD_ERR_CANT_WRITE]; // @codeCoverageIgnore + } + $bytes = fwrite($fp, $content); + if ($bytes === false) { + fclose($fp); // @codeCoverageIgnore + return ['tmp_name' => '', 'error' => UPLOAD_ERR_CANT_WRITE]; // @codeCoverageIgnore + } + $meta = stream_get_meta_data($fp); + $tmpName = isset($meta['uri']) ? $meta['uri'] : ''; + $this->tmpFileHandles[] = $fp; // retain handle for lifecycle + return ['tmp_name' => $tmpName, 'error' => UPLOAD_ERR_OK]; + } } diff --git a/flight/net/UploadedFile.php b/flight/net/UploadedFile.php index 2b3947b..c2f3ad3 100644 --- a/flight/net/UploadedFile.php +++ b/flight/net/UploadedFile.php @@ -33,6 +33,11 @@ class UploadedFile */ private int $error; + /** + * @var bool $isPostUploadedFile Indicates if the file was uploaded via POST method. + */ + private bool $isPostUploadedFile = false; + /** * Constructs a new UploadedFile object. * @@ -41,14 +46,20 @@ class UploadedFile * @param int $size The size of the uploaded file in bytes. * @param string $tmpName The temporary name of the uploaded file. * @param int $error The error code associated with the uploaded file. + * @param bool|null $isPostUploadedFile Indicates if the file was uploaded via POST method. */ - public function __construct(string $name, string $mimeType, int $size, string $tmpName, int $error) + public function __construct(string $name, string $mimeType, int $size, string $tmpName, int $error, ?bool $isPostUploadedFile = null) { $this->name = $name; $this->mimeType = $mimeType; $this->size = $size; $this->tmpName = $tmpName; $this->error = $error; + if (is_uploaded_file($tmpName) === true) { + $this->isPostUploadedFile = true; // @codeCoverageIgnore + } else { + $this->isPostUploadedFile = $isPostUploadedFile ?? false; + } } /** @@ -114,15 +125,42 @@ public function moveTo(string $targetPath): void throw new Exception($this->getUploadErrorMessage($this->error)); } - $isUploadedFile = is_uploaded_file($this->tmpName) === true; - if ( - $isUploadedFile === true - && - move_uploaded_file($this->tmpName, $targetPath) === false - ) { - throw new Exception('Cannot move uploaded file'); // @codeCoverageIgnore - } elseif ($isUploadedFile === false && getenv('PHPUNIT_TEST')) { - rename($this->tmpName, $targetPath); + if (is_writeable(dirname($targetPath)) === false) { + throw new Exception('Target directory is not writable'); + } + + // Prevent path traversal attacks + if (strpos($targetPath, '..') !== false) { + throw new Exception('Invalid target path: contains directory traversal'); + } + // Prevent absolute paths (basic check for Unix/Windows) + if ($targetPath[0] === '/' || (strlen($targetPath) > 1 && $targetPath[1] === ':')) { + throw new Exception('Invalid target path: absolute paths not allowed'); + } + + // Prevent overwriting existing files + if (file_exists($targetPath)) { + throw new Exception('Target file already exists'); + } + + // Check if this is a legitimate uploaded file (POST method uploads) + $isUploadedFile = $this->isPostUploadedFile; + + // Prevent symlink attacks for non-POST uploads + if (!$isUploadedFile && is_link($this->tmpName)) { + throw new Exception('Invalid temp file: symlink detected'); + } + + $uploadFunctionToCall = $isUploadedFile === true ? + // Standard POST upload - use move_uploaded_file for security + 'move_uploaded_file' : + // Handle non-POST uploads (PATCH, PUT, DELETE) or other valid temp files + 'rename'; + + $result = $uploadFunctionToCall($this->tmpName, $targetPath); + + if ($result === false) { + throw new Exception('Cannot move uploaded file'); } } diff --git a/tests/EngineTest.php b/tests/EngineTest.php index 91e9f3b..bdb5302 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -52,7 +52,7 @@ public function getInitializedVar() public function testInitBeforeStartV2OutputBuffering(): void { $engine = new class extends Engine { - public function getInitializedVar() + public function getInitializedVar(): bool { return $this->initialized; } @@ -122,7 +122,7 @@ public function testStartWithRoute(): void $_SERVER['REQUEST_URI'] = '/someRoute'; $engine = new class extends Engine { - public function getInitializedVar() + public function getInitializedVar(): bool { return $this->initialized; } @@ -141,7 +141,7 @@ public function testStartWithRouteButReturnedValueThrows404(): void $_SERVER['REQUEST_URI'] = '/someRoute'; $engine = new class extends Engine { - public function getInitializedVar() + public function getInitializedVar(): bool { return $this->initialized; } @@ -160,7 +160,7 @@ public function testStartWithRouteButReturnedValueThrows404V2OutputBuffering(): $_SERVER['REQUEST_URI'] = '/someRoute'; $engine = new class extends Engine { - public function getInitializedVar() + public function getInitializedVar(): bool { return $this->initialized; } @@ -180,7 +180,7 @@ public function testDoubleReturnTrueRoutesContinueIteration(): void $_SERVER['REQUEST_URI'] = '/someRoute'; $engine = new class extends Engine { - public function getInitializedVar() + public function getInitializedVar(): bool { return $this->initialized; } @@ -207,7 +207,7 @@ public function testDoubleReturnTrueWithMethodMismatchDuringIteration(): void $_SERVER['REQUEST_URI'] = '/someRoute'; $engine = new class extends Engine { - public function getInitializedVar() + public function getInitializedVar(): bool { return $this->initialized; } @@ -254,7 +254,7 @@ public function testIteratorReachesEndWithoutMatch(): void $_SERVER['REQUEST_URI'] = '/someRoute'; $engine = new class extends Engine { - public function getInitializedVar() + public function getInitializedVar(): bool { return $this->initialized; } @@ -276,7 +276,7 @@ public function getInitializedVar() $engine->start(); } - public function testDoubleStart() + public function testDoubleStart(): void { $engine = new Engine(); $engine->route('/someRoute', function () { @@ -512,7 +512,7 @@ public function testJsonWithDuplicateDefaultFlags() $this->assertEquals('{"key1":"value1","key2":"value2","utf8_emoji":"😀"}', $engine->response()->getBody()); } - public function testJsonThrowOnErrorByDefault() + public function testJsonThrowOnErrorByDefault(): void { $engine = new Engine(); $this->expectException(Exception::class); @@ -1000,7 +1000,7 @@ public function testContainerDiceBadMethod() { $engine->start(); } - public function testContainerPsr11() { + public function testContainerPsr11(): void { $engine = new Engine(); $container = new \League\Container\Container(); $container->add(Container::class)->addArgument(Collection::class)->addArgument(PdoWrapper::class); @@ -1031,7 +1031,7 @@ public function testContainerPsr11ClassNotFound() { $engine->start(); } - public function testContainerPsr11MethodNotFound() { + public function testContainerPsr11MethodNotFound(): void { $engine = new Engine(); $container = new \League\Container\Container(); $container->add(Container::class)->addArgument(Collection::class)->addArgument(PdoWrapper::class); @@ -1048,7 +1048,7 @@ public function testContainerPsr11MethodNotFound() { $engine->start(); } - public function testRouteFoundButBadMethod() { + public function testRouteFoundButBadMethod(): void { $engine = new class extends Engine { public function getLoader() { diff --git a/tests/RequestBodyParserTest.php b/tests/RequestBodyParserTest.php new file mode 100644 index 0000000..1cc5bdf --- /dev/null +++ b/tests/RequestBodyParserTest.php @@ -0,0 +1,449 @@ + '/', + 'base' => '/', + 'method' => $method, + 'referrer' => '', + 'ip' => '127.0.0.1', + 'ajax' => false, + 'scheme' => 'http', + 'user_agent' => 'Test', + 'type' => $contentType, + 'length' => strlen($body), + 'secure' => false, + 'accept' => '', + 'proxy_ip' => '', + 'host' => 'localhost', + 'servername' => 'localhost', + 'stream_path' => $stream_path, + 'data' => new Collection(), + 'query' => new Collection(), + 'cookies' => new Collection(), + 'files' => new Collection() + ]; + } + + private function assertUrlEncodedParsing(string $method): void + { + $body = 'foo=bar&baz=qux&key=value'; + $tmpfile = null; + $config = $this->createRequestConfig($method, 'application/x-www-form-urlencoded', $body, $tmpfile); + + $request = new Request($config); + + $expectedData = [ + 'foo' => 'bar', + 'baz' => 'qux', + 'key' => 'value' + ]; + $this->assertEquals($expectedData, $request->data->getData()); + + fclose($tmpfile); + } + + private function createMultipartBody(string $boundary, array $fields, array $files = []): string + { + $body = ''; + + // Add form fields + foreach ($fields as $name => $value) { + if (is_array($value)) { + foreach ($value as $item) { + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"{$name}\"\r\n"; + $body .= "\r\n"; + $body .= "{$item}\r\n"; + } + } else { + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"{$name}\"\r\n"; + $body .= "\r\n"; + $body .= "{$value}\r\n"; + } + } + + // Add files + foreach ($files as $name => $file) { + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"{$name}\"; filename=\"{$file['filename']}\"\r\n"; + $body .= "Content-Type: {$file['type']}\r\n"; + $body .= "\r\n"; + $body .= "{$file['content']}\r\n"; + } + + $body .= "--{$boundary}--\r\n"; + + return $body; + } + + public function testParseUrlEncodedBodyForPutMethod(): void + { + $this->assertUrlEncodedParsing('PUT'); + } + + public function testParseUrlEncodedBodyForPatchMethod(): void + { + $this->assertUrlEncodedParsing('PATCH'); + } + + public function testParseUrlEncodedBodyForDeleteMethod(): void + { + $this->assertUrlEncodedParsing('DELETE'); + } + + public function testParseMultipartFormDataWithFiles(): void + { + $boundary = 'boundary123456789'; + $fields = ['title' => 'Test Document']; + $files = [ + 'file' => [ + 'filename' => 'file.txt', + 'type' => 'text/plain', + 'content' => 'This is test file content' + ] + ]; + + $body = $this->createMultipartBody($boundary, $fields, $files); + $config = $this->createRequestConfig('PUT', "multipart/form-data; boundary={$boundary}", $body, $tmpfile); + $request = new Request($config); + + $this->assertEquals(['title' => 'Test Document'], $request->data->getData()); + + $file = $request->getUploadedFiles()['file']; + $this->assertEquals('file.txt', $file->getClientFilename()); + $this->assertEquals('text/plain', $file->getClientMediaType()); + $this->assertEquals(strlen('This is test file content'), $file->getSize()); + $this->assertEquals(UPLOAD_ERR_OK, $file->getError()); + $this->assertNotNull($file->getTempName()); + + fclose($tmpfile); + } + + public function testParseMultipartFormDataWithQuotedBoundary(): void + { + $boundary = 'boundary123456789'; + $fields = ['foo' => 'bar']; + + $body = $this->createMultipartBody($boundary, $fields); + $config = $this->createRequestConfig('PATCH', "multipart/form-data; boundary=\"{$boundary}\"", $body, $tmpfile); + $request = new Request($config); + + $this->assertEquals($fields, $request->data->getData()); + + fclose($tmpfile); + } + + public function testParseMultipartFormDataWithArrayFields(): void + { + $boundary = 'boundary123456789'; + $fields = ['name[]' => ['foo', 'bar']]; + $expectedData = ['name' => ['foo', 'bar']]; + + $body = $this->createMultipartBody($boundary, $fields); + $config = $this->createRequestConfig('PUT', "multipart/form-data; boundary={$boundary}", $body, $tmpfile); + $request = new Request($config); + + $this->assertEquals($expectedData, $request->data->getData()); + + fclose($tmpfile); + } + + public function testParseEmptyBody(): void + { + $config = $this->createRequestConfig('PUT', 'application/x-www-form-urlencoded', '', $tmpfile); + $request = new Request($config); + + $this->assertEquals([], $request->data->getData()); + + fclose($tmpfile); + } + + public function testParseInvalidMultipartWithoutBoundary(): void + { + $originalData = ['foo foo' => 'bar bar', 'baz baz' => 'qux']; + $body = http_build_query($originalData); + $expectedData = ['foo_foo' => 'bar bar', 'baz_baz' => 'qux']; + + $config = $this->createRequestConfig('PUT', 'multipart/form-data', $body, $tmpfile); // no boundary + $request = new Request($config); + + // should fall back to URL encoding and parse correctly + $this->assertEquals($expectedData, $request->data->getData()); + + fclose($tmpfile); + } + + public function testParseMultipartWithLargeFile(): void + { + $boundary = 'boundary123456789'; + $largeContent = str_repeat('A', 10000); // 10KB content + $files = [ + 'file' => [ + 'filename' => 'large.txt', + 'type' => 'text/plain', + 'content' => $largeContent + ] + ]; + + $body = $this->createMultipartBody($boundary, [], $files); + $config = $this->createRequestConfig('PUT', "multipart/form-data; boundary={$boundary}", $body, $tmpfile); + $request = new Request($config); + + $file = $request->getUploadedFiles()['file']; + $this->assertArrayHasKey('file', $request->getUploadedFiles()); + $this->assertEquals('large.txt', $file->getClientFilename()); + $this->assertEquals(10000, $file->getSize()); + $this->assertEquals(UPLOAD_ERR_OK, $file->getError()); + $this->assertNotNull($file->getTempName()); + + fclose($tmpfile); + } + + public function testGetMethodDoesNotTriggerParsing(): void + { + $body = 'foo=bar&baz=qux&key=value'; + $config = $this->createRequestConfig('GET', 'application/x-www-form-urlencoded', $body, $tmpfile); + $request = new Request($config); + + // GET method should not trigger parsing + $this->assertEquals([], $request->data->getData()); + + fclose($tmpfile); + } + + public function testPostMethodDoesNotTriggerParsing(): void + { + $body = 'foo=bar&baz=qux&key=value'; + $config = $this->createRequestConfig('POST', 'application/x-www-form-urlencoded', $body, $tmpfile); + $request = new Request($config); + + // POST method should not trigger this parsing (uses $_POST instead) + $this->assertEquals([], $request->data->getData()); + + fclose($tmpfile); + } + + /** + * Tests getUploadMaxFileSize parsing for various php.ini unit suffixes. + * We'll call the method in-process after setting ini values via ini_set + * and also simulate a value with unknown unit to hit the default branch. + */ + public function testGetUploadMaxFileSizeUnits(): void + { + // Use PHP CLI with -d to set upload_max_filesize (ini_set can't change this setting in many SAPIs) + $cases = [ + // No unit yields default branch which returns 0 in current implementation + ['1' , 0], // no unit and number too small + ['1K' , 1024], + ['2M' , 2 * 1024 * 1024], + ['1G' , 1024 * 1024 * 1024], + ['1T' , 1024 * 1024 * 1024 * 1024], + ['1Z' , 0 ], // Unknown unit and number too small + [ '1024', 1024 ] + ]; + + foreach ($cases as [$iniVal, $expected]) { + $actual = Request::parsePhpSize($iniVal); + $this->assertEquals($expected, $actual, "upload_max_filesize={$iniVal}"); + } + } + + /** + * Helper: run PHP CLI with -d upload_max_filesize and return the Request::getUploadMaxFileSize() result. + */ + // removed CLI helper; parsePhpSize covers unit parsing and is pure + + public function testMultipartBoundaryInvalidFallsBackToUrlEncoded(): void + { + // Body doesn't start with boundary marker => fallback to urlencoded branch + $body = 'field1=value1&field2=value2'; + $tmp = tmpfile(); + $path = stream_get_meta_data($tmp)['uri']; + file_put_contents($path, $body); + + $request = new Request([ + 'url' => '/upload', + 'base' => '/', + 'method' => 'PATCH', + 'type' => 'multipart/form-data; boundary=BOUNDARYXYZ', // claims multipart + 'stream_path' => $path, + 'data' => new Collection(), + 'query' => new Collection(), + 'files' => new Collection(), + ]); + + $this->assertEquals(['field1' => 'value1', 'field2' => 'value2'], $request->data->getData()); + $this->assertSame([], $request->files->getData()); + } + + public function testMultipartParsingEdgeCases(): void + { + $boundary = 'MBOUND123'; + $parts = []; + + // A: invalid split (no blank line) => skipped + $parts[] = "Content-Disposition: form-data; name=\"skipnosplit\""; // no value portion + + // B: missing content-disposition entirely => skipped + $parts[] = "Content-Type: text/plain\r\n\r\nignoredvalue"; + + // C: header too long (>16384) => skipped + $longHeader = 'Content-Disposition: form-data; name="toolong"; filename="toolong.txt"; ' . str_repeat('x', 16500); + $parts[] = $longHeader . "\r\n\r\nlongvalue"; + + // D: header line without colon gets skipped but rest processed; becomes non-file field + $parts[] = "BadHeaderLine\r\nContent-Disposition: form-data; name=\"fieldX\"\r\n\r\nvalueX"; + + // E: disposition without name => skipped + $parts[] = "Content-Disposition: form-data; filename=\"nofname.txt\"\r\n\r\nnoNameValue"; + + // F: empty name => skipped + $parts[] = "Content-Disposition: form-data; name=\"\"; filename=\"empty.txt\"\r\n\r\nemptyNameValue"; + + // G: invalid filename triggers sanitized fallback + $parts[] = "Content-Disposition: form-data; name=\"filebad\"; filename=\"a*b?.txt\"\r\nContent-Type: text/plain\r\n\r\nFILEBAD"; + + // H1 & H2: two files same key for aggregation logic (arrays) + $parts[] = "Content-Disposition: form-data; name=\"filemulti\"; filename=\"one.txt\"\r\nContent-Type: text/plain\r\n\r\nONE"; + $parts[] = "Content-Disposition: form-data; name=\"filemulti\"; filename=\"two.txt\"\r\nContent-Type: text/plain\r\n\r\nTWO"; + + // I: file exceeding total bytes triggers UPLOAD_ERR_INI_SIZE + $parts[] = "Content-Disposition: form-data; name=\"filebig\"; filename=\"big.txt\"\r\nContent-Type: text/plain\r\n\r\n" . str_repeat('A', 10); + + // Build full body + $body = ''; + foreach ($parts as $p) { + $body .= '--' . $boundary . "\r\n" . $p . "\r\n"; + } + $body .= '--' . $boundary . "--\r\n"; + + $tmp = tmpfile(); + $path = stream_get_meta_data($tmp)['uri']; + file_put_contents($path, $body); + + $request = new Request([ + 'url' => '/upload', + 'base' => '/', + 'method' => 'PATCH', + 'type' => 'multipart/form-data; boundary=' . $boundary, + 'stream_path' => $path, + 'data' => new Collection(), + 'query' => new Collection(), + 'files' => new Collection(), + ]); + + $data = $request->data->getData(); + $this->assertArrayHasKey('fieldX', $data); // only processed non-file field + $this->assertEquals('valueX', $data['fieldX']); + $files = $request->files->getData(); + + // filebad fallback name + $this->assertArrayHasKey('filebad', $files); + $this->assertMatchesRegularExpression('/^upload_/', $files['filebad']['name']); + + // filemulti aggregated arrays + $this->assertArrayHasKey('filemulti', $files); + $this->assertEquals(['one.txt', 'two.txt'], $files['filemulti']['name']); + $this->assertEquals(['text/plain', 'text/plain'], $files['filemulti']['type']); + + // filebig error path + $this->assertArrayHasKey('filebig', $files); + $uploadMax = Request::parsePhpSize(ini_get('upload_max_filesize')); + $postMax = Request::parsePhpSize(ini_get('post_max_size')); + $shouldError = ($uploadMax > 0 && $uploadMax < 10) || ($postMax > 0 && $postMax < 10); + if ($shouldError) { + $this->assertEquals(UPLOAD_ERR_INI_SIZE, $files['filebig']['error']); + } else { + $this->assertEquals(UPLOAD_ERR_OK, $files['filebig']['error']); + } + } + + + public function testMultipartEmptyArrayNameStripped(): void + { + // Covers line where keyName becomes empty after removing [] (name="[]") and header param extraction (preg_match_all) + $boundary = 'BOUNDARYEMPTY'; + $validFilePart = "Content-Disposition: form-data; name=\"fileok\"; filename=\"ok.txt\"\r\nContent-Type: text/plain\r\n\r\nOK"; + $emptyNameFilePart = "Content-Disposition: form-data; name=\"[]\"; filename=\"empty.txt\"\r\nContent-Type: text/plain\r\n\r\nSHOULD_SKIP"; + $body = '--' . $boundary . "\r\n" . $validFilePart . "\r\n" . '--' . $boundary . "\r\n" . $emptyNameFilePart . "\r\n" . '--' . $boundary . "--\r\n"; + + $tmp = tmpfile(); + $path = stream_get_meta_data($tmp)['uri']; + file_put_contents($path, $body); + + $request = new Request([ + 'url' => '/upload', + 'base' => '/', + 'method' => 'PATCH', + 'type' => 'multipart/form-data; boundary=' . $boundary, + 'stream_path' => $path, + 'data' => new Collection(), + 'query' => new Collection(), + 'files' => new Collection(), + ]); + + $files = $request->files->getData(); + // fileok processed + $this->assertArrayHasKey('fileok', $files); + // name="[]" stripped => keyName becomes empty -> skipped + $this->assertArrayNotHasKey('empty', $files); // just to show not mistakenly created + $this->assertCount(5, $files['fileok']); // meta keys name,type,size,tmp_name,error + } + + public function testMultipartMalformedBoundaryFallsBackToUrlEncoded(): void + { + // boundary has invalid characters (spaces) so regex validation fails -> line 589 path + $invalidBoundary = 'BAD BOUNDARY WITH SPACE'; + $body = 'alpha=1&beta=2'; // should parse as urlencoded after fallback + $tmp = tmpfile(); + $path = stream_get_meta_data($tmp)['uri']; + file_put_contents($path, $body); + + $request = new Request([ + 'url' => '/upload', + 'base' => '/', + 'method' => 'PATCH', + 'type' => 'multipart/form-data; boundary=' . $invalidBoundary, + 'stream_path' => $path, + 'data' => new Collection(), + 'query' => new Collection(), + 'files' => new Collection(), + ]); + + $this->assertEquals(['alpha' => '1', 'beta' => '2'], $request->data->getData()); + $this->assertSame([], $request->files->getData()); + } +} diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 214b26e..38321ea 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -330,31 +330,78 @@ public function testGetSingleFileUpload(): void public function testGetMultiFileUpload(): void { - $_FILES['files'] = [ - 'name' => ['file1.txt', 'file2.txt'], - 'type' => ['text/plain', 'text/plain'], - 'size' => [123, 456], - 'tmp_name' => ['/tmp/php123', '/tmp/php456'], + // Arrange: Setup multiple file upload arrays + $_FILES['files_1'] = [ + 'name' => 'file1.txt', + 'type' => 'text/plain', + 'size' => 123, + 'tmp_name' => '/tmp/php123', + 'error' => 0 + ]; + $_FILES['files_2'] = [ + 'name' => ['file2.txt'], + 'type' => ['text/plain'], + 'size' => [456], + 'tmp_name' => ['/tmp/php456'], + 'error' => [0] + ]; + $_FILES['files_3'] = [ + 'name' => ['file3.txt', 'file4.txt'], + 'type' => ['text/html', 'application/json'], + 'size' => [789, 321], + 'tmp_name' => ['/tmp/php789', '/tmp/php321'], 'error' => [0, 0] ]; + // Act $request = new Request(); + $uploadedFiles = $request->getUploadedFiles(); + + // Assert: Verify first file group (single file) + /* + + */ + $firstFile = $uploadedFiles['files_1'] ?? null; + $this->assertNotNull($firstFile, 'First file should exist'); + $this->assertUploadedFile($firstFile, 'file1.txt', 'text/plain', 123, '/tmp/php123', 0); + + // Assert: Verify second file group (array format with single file) + /* + + */ + $secondGroup = $uploadedFiles['files_2'] ?? []; + $this->assertCount(1, $secondGroup, 'Second file group should contain 1 file in array format'); + + $this->assertUploadedFile($secondGroup[0], 'file2.txt', 'text/plain', 456, '/tmp/php456', 0); + + // Assert: Verify third file group (multiple files) + /* + + + */ + $thirdGroup = $uploadedFiles['files_3'] ?? []; + $this->assertCount(2, $thirdGroup, 'Third file group should contain 2 files'); + + $this->assertUploadedFile($thirdGroup[0], 'file3.txt', 'text/html', 789, '/tmp/php789', 0); + $this->assertUploadedFile($thirdGroup[1], 'file4.txt', 'application/json', 321, '/tmp/php321', 0); + } - $files = $request->getUploadedFiles()['files']; - - $this->assertCount(2, $files); - - $this->assertEquals('file1.txt', $files[0]->getClientFilename()); - $this->assertEquals('text/plain', $files[0]->getClientMediaType()); - $this->assertEquals(123, $files[0]->getSize()); - $this->assertEquals('/tmp/php123', $files[0]->getTempName()); - $this->assertEquals(0, $files[0]->getError()); - - $this->assertEquals('file2.txt', $files[1]->getClientFilename()); - $this->assertEquals('text/plain', $files[1]->getClientMediaType()); - $this->assertEquals(456, $files[1]->getSize()); - $this->assertEquals('/tmp/php456', $files[1]->getTempName()); - $this->assertEquals(0, $files[1]->getError()); + /** + * Helper method to assert uploaded file properties + */ + private function assertUploadedFile( + $file, + string $expectedName, + string $expectedType, + int $expectedSize, + string $expectedTmpName, + int $expectedError + ): void { + $this->assertEquals($expectedName, $file->getClientFilename()); + $this->assertEquals($expectedType, $file->getClientMediaType()); + $this->assertEquals($expectedSize, $file->getSize()); + $this->assertEquals($expectedTmpName, $file->getTempName()); + $this->assertEquals($expectedError, $file->getError()); } public function testUrlWithAtSymbol(): void diff --git a/tests/RouterTest.php b/tests/RouterTest.php index c0ed3c1..620ecc3 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -39,7 +39,7 @@ public function ok(): void } // Checks if a route was matched with a given output - public function check($str = '') + public function check($str = ''): void { /*$route = $this->router->route($this->request); @@ -502,7 +502,7 @@ public function testResetRoutes(): void { $router = new class extends Router { - public function getIndex() + public function getIndex(): int { return $this->index; } diff --git a/tests/SimplePdoTest.php b/tests/SimplePdoTest.php new file mode 100644 index 0000000..a8e85b2 --- /dev/null +++ b/tests/SimplePdoTest.php @@ -0,0 +1,458 @@ +db = new SimplePdo('sqlite::memory:'); + // Create a test table and insert 3 rows of data + $this->db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); + $this->db->exec('INSERT INTO users (name, email) VALUES ("John", "john@example.com")'); + $this->db->exec('INSERT INTO users (name, email) VALUES ("Jane", "jane@example.com")'); + $this->db->exec('INSERT INTO users (name, email) VALUES ("Bob", "bob@example.com")'); + } + + protected function tearDown(): void + { + $this->db->exec('DROP TABLE users'); + } + + // ========================================================================= + // Constructor Tests + // ========================================================================= + + public function testDefaultFetchModeIsAssoc(): void + { + $fetchMode = $this->db->getAttribute(PDO::ATTR_DEFAULT_FETCH_MODE); + $this->assertEquals(PDO::FETCH_ASSOC, $fetchMode); + } + + public function testCustomFetchModeCanBeSet(): void + { + $db = new SimplePdo('sqlite::memory:', null, null, [ + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ + ]); + $fetchMode = $db->getAttribute(PDO::ATTR_DEFAULT_FETCH_MODE); + $this->assertEquals(PDO::FETCH_OBJ, $fetchMode); + } + + public function testApmTrackingOffByDefault(): void + { + // APM is off by default, so logQueries should not trigger events + // We test this indirectly by calling logQueries and ensuring no error + $this->db->logQueries(); + $this->assertTrue(true); // If we get here, no exception was thrown + } + + public function testApmTrackingCanBeEnabled(): void + { + $db = new SimplePdo('sqlite::memory:', null, null, null, [ + 'trackApmQueries' => true + ]); + $db->exec('CREATE TABLE test (id INTEGER PRIMARY KEY)'); + $db->runQuery('SELECT * FROM test'); + $db->logQueries(); // Should work without error + $this->assertTrue(true); + } + + // ========================================================================= + // runQuery Tests + // ========================================================================= + + public function testRunQueryReturnsStatement(): void + { + $stmt = $this->db->runQuery('SELECT * FROM users'); + $this->assertInstanceOf(PDOStatement::class, $stmt); + } + + public function testRunQueryWithParams(): void + { + $stmt = $this->db->runQuery('SELECT * FROM users WHERE name = ?', ['John']); + $rows = $stmt->fetchAll(); + $this->assertCount(1, $rows); + $this->assertEquals('John', $rows[0]['name']); + } + + public function testRunQueryWithoutParamsWithMaxQueryMetrics(): void + { + $db = new class ('sqlite::memory:', null, null, null, ['maxQueryMetrics' => 2, 'trackApmQueries' => true]) extends SimplePdo { + public function getQueryMetrics(): array + { + return $this->queryMetrics; + } + }; + $db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); + $db->exec('INSERT INTO users (name, email) VALUES ("John", "john@example.com")'); + $db->exec('INSERT INTO users (name, email) VALUES ("Jane", "jane@example.com")'); + $db->exec('INSERT INTO users (name, email) VALUES ("Bob", "bob@example.com")'); + + $db->runQuery('SELECT * FROM users WHERE 1 = 1'); + $db->runQuery('SELECT * FROM users WHERE 1 = 2'); + $this->assertEquals(2, count($db->getQueryMetrics())); + $db->runQuery('SELECT * FROM users WHERE 1 = 3'); + $dbMetrics = $db->getQueryMetrics(); + $this->assertEquals(2, count($dbMetrics)); + $this->assertEquals('SELECT * FROM users WHERE 1 = 2', $dbMetrics[0]['sql']); + $this->assertEquals('SELECT * FROM users WHERE 1 = 3', $dbMetrics[1]['sql']); + } + + public function testRunQueryInsert(): void + { + $stmt = $this->db->runQuery('INSERT INTO users (name, email) VALUES (?, ?)', ['Alice', 'alice@example.com']); + $this->assertEquals(1, $stmt->rowCount()); + } + + public function testRunQueryThrowsOnPrepareFailure(): void + { + $this->expectException(PDOException::class); + $this->db->runQuery('SELECT * FROM nonexistent_table'); + } + + // ========================================================================= + // fetchRow Tests + // ========================================================================= + + public function testFetchRowReturnsCollection(): void + { + $row = $this->db->fetchRow('SELECT * FROM users WHERE id = ?', [1]); + $this->assertInstanceOf(Collection::class, $row); + $this->assertEquals('John', $row['name']); + } + + public function testFetchRowReturnsNullWhenNoResults(): void + { + $row = $this->db->fetchRow('SELECT * FROM users WHERE id = ?', [999]); + $this->assertNull($row); + } + + public function testFetchRowAddsLimitAutomatically(): void + { + // Even though there are 3 rows, fetchRow should only return 1 + $row = $this->db->fetchRow('SELECT * FROM users'); + $this->assertInstanceOf(Collection::class, $row); + $this->assertEquals(1, $row['id']); + } + + public function testFetchRowDoesNotDuplicateLimitClause(): void + { + // Query already has LIMIT - should not add another + $row = $this->db->fetchRow('SELECT * FROM users ORDER BY id DESC LIMIT 1'); + $this->assertInstanceOf(Collection::class, $row); + $this->assertEquals(3, $row['id']); // Should be Bob (id=3) + } + + public function testFetchRowDoesNotAddLimitAfterReturningClause(): void + { + $row = $this->db->fetchRow( + 'INSERT INTO users (name, email) VALUES (?, ?) RETURNING id, name', + ['Alice', 'alice@example.com'] + ); + + $this->assertInstanceOf(Collection::class, $row); + $this->assertSame('Alice', $row['name']); + } + + // ========================================================================= + // fetchAll Tests + // ========================================================================= + + public function testFetchAllReturnsArrayOfCollections(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users'); + $this->assertIsArray($rows); + $this->assertCount(3, $rows); + $this->assertInstanceOf(Collection::class, $rows[0]); + } + + public function testFetchAllReturnsEmptyArrayWhenNoResults(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE 1 = 0'); + $this->assertIsArray($rows); + $this->assertCount(0, $rows); + } + + public function testFetchAllWithParams(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE name LIKE ?', ['J%']); + $this->assertCount(2, $rows); // John and Jane + } + + // ========================================================================= + // fetchColumn Tests + // ========================================================================= + + public function testFetchColumnReturnsFlatArray(): void + { + $names = $this->db->fetchColumn('SELECT name FROM users ORDER BY id'); + $this->assertIsArray($names); + $this->assertEquals(['John', 'Jane', 'Bob'], $names); + } + + public function testFetchColumnWithParams(): void + { + $ids = $this->db->fetchColumn('SELECT id FROM users WHERE name LIKE ?', ['J%']); + $this->assertEquals([1, 2], $ids); + } + + public function testFetchColumnReturnsEmptyArrayWhenNoResults(): void + { + $result = $this->db->fetchColumn('SELECT id FROM users WHERE 1 = 0'); + $this->assertEquals([], $result); + } + + // ========================================================================= + // fetchPairs Tests + // ========================================================================= + + public function testFetchPairsReturnsKeyValueArray(): void + { + $pairs = $this->db->fetchPairs('SELECT id, name FROM users ORDER BY id'); + $this->assertEquals([1 => 'John', 2 => 'Jane', 3 => 'Bob'], $pairs); + } + + public function testFetchPairsWithParams(): void + { + $pairs = $this->db->fetchPairs('SELECT id, email FROM users WHERE name = ?', ['John']); + $this->assertEquals([1 => 'john@example.com'], $pairs); + } + + public function testFetchPairsReturnsEmptyArrayWhenNoResults(): void + { + $pairs = $this->db->fetchPairs('SELECT id, name FROM users WHERE 1 = 0'); + $this->assertEquals([], $pairs); + } + + // ========================================================================= + // IN Statement Processing Tests + // ========================================================================= + + public function testInStatementWithArrayOfIntegers(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE id IN(?)', [[1, 2]]); + $this->assertCount(2, $rows); + } + + public function testInStatementWithArrayOfStrings(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE name IN(?)', [['John', 'Jane']]); + $this->assertCount(2, $rows); + } + + public function testInStatementWithEmptyArray(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE id IN(?)', [[]]); + $this->assertCount(0, $rows); // IN(NULL) matches nothing + } + + public function testInStatementWithSingleValue(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE id IN(?)', [1]); + $this->assertCount(1, $rows); + } + + public function testMultipleInStatements(): void + { + $rows = $this->db->fetchAll( + 'SELECT * FROM users WHERE id IN(?) AND name IN(?)', + [[1, 2, 3], ['John', 'Bob']] + ); + $this->assertCount(2, $rows); // John (id=1) and Bob (id=3) + } + + public function testInStatementWithOtherParams(): void + { + $rows = $this->db->fetchAll( + 'SELECT * FROM users WHERE id > ? AND name IN(?)', + [0, ['John', 'Jane']] + ); + $this->assertCount(2, $rows); + } + + // ========================================================================= + // insert() Tests + // ========================================================================= + + public function testInsertSingleRow(): void + { + $id = $this->db->insert('users', ['name' => 'Alice', 'email' => 'alice@example.com']); + $this->assertEquals('4', $id); + + $row = $this->db->fetchRow('SELECT * FROM users WHERE id = ?', [$id]); + $this->assertEquals('Alice', $row['name']); + $this->assertEquals('alice@example.com', $row['email']); + } + + public function testInsertBulkRows(): void + { + $id = $this->db->insert('users', [ + ['name' => 'Alice', 'email' => 'alice@example.com'], + ['name' => 'Charlie', 'email' => 'charlie@example.com'], + ]); + + // Last insert ID should be 5 (Charlie) + $this->assertEquals('5', $id); + + // Verify both rows were inserted + $rows = $this->db->fetchAll('SELECT * FROM users WHERE id > 3 ORDER BY id'); + $this->assertCount(2, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals('Charlie', $rows[1]['name']); + } + + public function testInsertBulkWithEmptyArrayThrows(): void + { + $this->expectException(PDOException::class); + $this->expectExceptionMessage('Cannot perform bulk insert with empty data array'); + + $this->db->insert('users', [[]]); + } + + public function testInsertBulkWithMismatchedColumnCountThrows(): void + { + $this->expectException(PDOException::class); + $this->expectExceptionMessage('columns'); + + $this->db->insert('users', [ + ['name' => 'Alice', 'email' => 'alice@example.com'], + ['name' => 'Charlie'], // Missing email column + ]); + } + + // ========================================================================= + // update() Tests + // ========================================================================= + + public function testUpdateReturnsAffectedRowCount(): void + { + $count = $this->db->update('users', ['name' => 'Updated'], 'name LIKE ?', ['J%']); + $this->assertEquals(2, $count); // John and Jane + + $rows = $this->db->fetchAll('SELECT * FROM users WHERE name = ?', ['Updated']); + $this->assertCount(2, $rows); + } + + public function testUpdateSingleRow(): void + { + $count = $this->db->update('users', ['email' => 'newemail@example.com'], 'id = ?', [1]); + $this->assertEquals(1, $count); + + $row = $this->db->fetchRow('SELECT * FROM users WHERE id = ?', [1]); + $this->assertEquals('newemail@example.com', $row['email']); + } + + public function testUpdateNoMatchingRows(): void + { + $count = $this->db->update('users', ['name' => 'Nobody'], 'id = ?', [999]); + $this->assertEquals(0, $count); + } + + // ========================================================================= + // delete() Tests + // ========================================================================= + + public function testDeleteReturnsDeletedRowCount(): void + { + $count = $this->db->delete('users', 'name LIKE ?', ['J%']); + $this->assertEquals(2, $count); // John and Jane + + $rows = $this->db->fetchAll('SELECT * FROM users'); + $this->assertCount(1, $rows); + $this->assertEquals('Bob', $rows[0]['name']); + } + + public function testDeleteSingleRow(): void + { + $count = $this->db->delete('users', 'id = ?', [1]); + $this->assertEquals(1, $count); + + $rows = $this->db->fetchAll('SELECT * FROM users'); + $this->assertCount(2, $rows); + } + + public function testDeleteNoMatchingRows(): void + { + $count = $this->db->delete('users', 'id = ?', [999]); + $this->assertEquals(0, $count); + } + + // ========================================================================= + // transaction() Tests + // ========================================================================= + + public function testTransactionCommitsOnSuccess(): void + { + $result = $this->db->transaction(function ($db) { + $db->runQuery('INSERT INTO users (name, email) VALUES (?, ?)', ['Alice', 'alice@example.com']); + return $db->lastInsertId(); + }); + + $this->assertEquals('4', $result); + + $row = $this->db->fetchRow('SELECT * FROM users WHERE id = ?', [4]); + $this->assertEquals('Alice', $row['name']); + } + + public function testTransactionRollsBackOnException(): void + { + try { + $this->db->transaction(function ($db) { + $db->runQuery('INSERT INTO users (name, email) VALUES (?, ?)', ['Alice', 'alice@example.com']); + throw new \RuntimeException('Something went wrong'); + }); + } catch (\RuntimeException $e) { + $this->assertEquals('Something went wrong', $e->getMessage()); + } + + // Verify the insert was rolled back + $rows = $this->db->fetchAll('SELECT * FROM users'); + $this->assertCount(3, $rows); // Still only the original 3 rows + } + + public function testTransactionReturnsCallbackValue(): void + { + $result = $this->db->transaction(function () { + return 'hello world'; + }); + + $this->assertEquals('hello world', $result); + } + + public function testTransactionRethrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Test exception'); + + $this->db->transaction(function () { + throw new \InvalidArgumentException('Test exception'); + }); + } + + // ========================================================================= + // fetchField (inherited from PdoWrapper) Tests + // ========================================================================= + + public function testFetchFieldReturnsValue(): void + { + $name = $this->db->fetchField('SELECT name FROM users WHERE id = ?', [1]); + $this->assertEquals('John', $name); + } + + public function testFetchFieldReturnsFirstColumn(): void + { + $id = $this->db->fetchField('SELECT id, name FROM users WHERE id = ?', [1]); + $this->assertEquals(1, $id); + } +} diff --git a/tests/UploadedFileTest.php b/tests/UploadedFileTest.php index 05cdc95..f8817f3 100644 --- a/tests/UploadedFileTest.php +++ b/tests/UploadedFileTest.php @@ -18,14 +18,24 @@ public function tearDown(): void if (file_exists('tmp_name')) { unlink('tmp_name'); } + if (file_exists('existing.txt')) { + unlink('existing.txt'); + } + if (file_exists('real_file')) { + unlink('real_file'); + } + + // not found with file_exists...just delete it brute force + @unlink('tmp_symlink'); } - public function testMoveToSuccess(): void + public function testMoveToFalseSuccess(): void { + // This test would have passed in the real world but we can't actually force a post request in unit tests file_put_contents('tmp_name', 'test'); - $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, true); + $this->expectExceptionMessage('Cannot move uploaded file'); $uploadedFile->moveTo('file.txt'); - $this->assertFileExists('file.txt'); } public function getFileErrorMessageTests(): array @@ -45,7 +55,7 @@ public function getFileErrorMessageTests(): array /** * @dataProvider getFileErrorMessageTests */ - public function testMoveToFailureMessages($error, $message) + public function testMoveToFailureMessages(int $error, string $message): void { file_put_contents('tmp_name', 'test'); $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', $error); @@ -53,4 +63,62 @@ public function testMoveToFailureMessages($error, $message) $this->expectExceptionMessage($message); $uploadedFile->moveTo('file.txt'); } + + public function testMoveToBadLocation(): void + { + file_put_contents('tmp_name', 'test'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, true); + $this->expectExceptionMessage('Target directory is not writable'); + $uploadedFile->moveTo('/root/file.txt'); + } + + public function testMoveToSuccessNonPost(): void + { + file_put_contents('tmp_name', 'test'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, false); + $uploadedFile->moveTo('file.txt'); + $this->assertFileExists('file.txt'); + $this->assertEquals('test', file_get_contents('file.txt')); + } + + public function testMoveToPathTraversal(): void + { + file_put_contents('tmp_name', 'test'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, false); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid target path: contains directory traversal'); + $uploadedFile->moveTo('../file.txt'); + } + + public function testMoveToAbsolutePath(): void + { + file_put_contents('tmp_name', 'test'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, false); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid target path: absolute paths not allowed'); + $uploadedFile->moveTo('/tmp/file.txt'); + } + + public function testMoveToOverwrite(): void + { + file_put_contents('tmp_name', 'test'); + file_put_contents('existing.txt', 'existing'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, false); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Target file already exists'); + $uploadedFile->moveTo('existing.txt'); + } + + public function testMoveToSymlinkNonPost(): void + { + file_put_contents('real_file', 'test'); + if (file_exists('tmp_symlink')) { + unlink('tmp_symlink'); + } + symlink('real_file', 'tmp_symlink'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_symlink', UPLOAD_ERR_OK, false); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid temp file: symlink detected'); + $uploadedFile->moveTo('file.txt'); + } } diff --git a/tests/classes/NoExitInteractor.php b/tests/classes/NoExitInteractor.php new file mode 100644 index 0000000..737f49c --- /dev/null +++ b/tests/classes/NoExitInteractor.php @@ -0,0 +1,21 @@ +writer()->error($text, 0); + return $this; + } + public function warn(string $text, bool $exit = false): self + { + $this->writer()->warn($text, 0); + return $this; + } +} diff --git a/tests/commands/AiGenerateInstructionsCommandTest.php b/tests/commands/AiGenerateInstructionsCommandTest.php index 6e09484..c874a4b 100644 --- a/tests/commands/AiGenerateInstructionsCommandTest.php +++ b/tests/commands/AiGenerateInstructionsCommandTest.php @@ -5,16 +5,15 @@ namespace tests\commands; use Ahc\Cli\Application; -use Ahc\Cli\IO\Interactor; use flight\commands\AiGenerateInstructionsCommand; use PHPUnit\Framework\TestCase; +use tests\classes\NoExitInteractor; class AiGenerateInstructionsCommandTest extends TestCase { protected static $in; protected static $ou; protected $baseDir; - protected $runwayCredsFile; public function setUp(): void { @@ -26,16 +25,6 @@ public function setUp(): void if (!is_dir($this->baseDir)) { mkdir($this->baseDir, 0777, true); } - $this->runwayCredsFile = $this->baseDir . 'dummy-creds.json'; - if (file_exists($this->runwayCredsFile)) { - unlink($this->runwayCredsFile); - } - @unlink($this->baseDir . '.github/copilot-instructions.md'); - @unlink($this->baseDir . '.cursor/rules/project-overview.mdc'); - @unlink($this->baseDir . '.windsurfrules'); - @rmdir($this->baseDir . '.github'); - @rmdir($this->baseDir . '.cursor/rules'); - @rmdir($this->baseDir . '.cursor'); } public function tearDown(): void @@ -46,27 +35,19 @@ public function tearDown(): void if (file_exists(self::$ou)) { unlink(self::$ou); } - if (file_exists($this->runwayCredsFile)) { - unlink($this->runwayCredsFile); - } - @unlink($this->baseDir . '.github/copilot-instructions.md'); - @unlink($this->baseDir . '.cursor/rules/project-overview.mdc'); - @unlink($this->baseDir . '.windsurfrules'); - @rmdir($this->baseDir . '.github'); - @rmdir($this->baseDir . '.cursor/rules'); - @rmdir($this->baseDir . '.cursor'); - if (is_dir($this->baseDir . '.cursor/rules')) { - @rmdir($this->baseDir . '.cursor/rules'); - } - if (is_dir($this->baseDir . '.cursor')) { - @rmdir($this->baseDir . '.cursor'); - } - if (is_dir($this->baseDir . '.github')) { - @rmdir($this->baseDir . '.github'); + $this->recursiveRmdir($this->baseDir); + } + + protected function recursiveRmdir($dir) + { + if (!is_dir($dir)) { + return; } - if (is_dir($this->baseDir)) { - @rmdir($this->baseDir); + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? $this->recursiveRmdir("$dir/$file") : unlink("$dir/$file"); } + return rmdir($dir); } protected function newApp($command): Application @@ -74,7 +55,7 @@ protected function newApp($command): Application $app = new Application('test', '0.0.1', function ($exitCode) { return $exitCode; }); - $app->io(new Interactor(self::$in, self::$ou)); + $app->io(new NoExitInteractor(self::$in, self::$ou)); $app->add($command); return $app; } @@ -84,22 +65,54 @@ protected function setInput(array $lines): void file_put_contents(self::$in, implode("\n", $lines) . "\n"); } - public function testFailsIfCredsFileMissing() + protected function setProjectRoot($command, $path) + { + $reflection = new \ReflectionClass(get_class($command)); + $property = null; + $currentClass = $reflection; + while ($currentClass && !$property) { + try { + $property = $currentClass->getProperty('projectRoot'); + } catch (\ReflectionException $e) { + $currentClass = $currentClass->getParentClass(); + } + } + if ($property) { + // only setAccessible if php 8 or php 7.4 + if (PHP_VERSION_ID < 80100) { + $property->setAccessible(true); + } + $property->setValue($command, $path); + } + } + + public function testFailsIfAiConfigMissing() { $this->setInput([ - 'desc', 'none', 'latte', 'y', 'y', 'none', 'Docker', '1', 'n', 'no' + 'desc', + 'none', + 'latte', + 'y', + 'y', + 'none', + 'Docker', + '1', + 'n', + 'no' ]); + // Provide 'runway' with dummy data to avoid deprecated configFile logic $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([['runway' => ['dummy' => true]]]) ->onlyMethods(['callLlmApi']) ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); $app = $this->newApp($cmd); $result = $app->handle([ - 'runway', 'ai:generate-instructions', - '--creds-file=' . $this->runwayCredsFile, - '--base-dir=' . $this->baseDir + 'runway', + 'ai:generate-instructions', ]); $this->assertSame(1, $result); - $this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md'); + $this->assertStringContainsString('Missing AI configuration', file_get_contents(self::$ou)); } public function testWritesInstructionsToFiles() @@ -109,14 +122,28 @@ public function testWritesInstructionsToFiles() 'model' => 'gpt-4o', 'base_url' => 'https://api.openai.com', ]; - file_put_contents($this->runwayCredsFile, json_encode($creds)); $this->setInput([ - 'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info' + 'desc', + 'mysql', + 'latte', + 'y', + 'y', + 'flight/lib', + 'Docker', + '2', + 'y', + 'context info' ]); $mockInstructions = "# Project Instructions\n\nUse MySQL, Latte, Docker."; $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([ + [ + 'runway' => ['ai' => $creds] + ] + ]) ->onlyMethods(['callLlmApi']) ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); $cmd->expects($this->once()) ->method('callLlmApi') ->willReturn(json_encode([ @@ -126,17 +153,14 @@ public function testWritesInstructionsToFiles() ])); $app = $this->newApp($cmd); $result = $app->handle([ - 'runway', 'ai:generate-instructions', - '--creds-file=' . $this->runwayCredsFile, - '--base-dir=' . $this->baseDir + 'runway', + 'ai:generate-instructions', ]); $this->assertSame(0, $result); $this->assertFileExists($this->baseDir . '.github/copilot-instructions.md'); $this->assertFileExists($this->baseDir . '.cursor/rules/project-overview.mdc'); + $this->assertFileExists($this->baseDir . '.gemini/GEMINI.md'); $this->assertFileExists($this->baseDir . '.windsurfrules'); - $this->assertStringContainsString('MySQL', file_get_contents($this->baseDir . '.github/copilot-instructions.md')); - $this->assertStringContainsString('MySQL', file_get_contents($this->baseDir . '.cursor/rules/project-overview.mdc')); - $this->assertStringContainsString('MySQL', file_get_contents($this->baseDir . '.windsurfrules')); } public function testNoInstructionsReturnedFromLlm() @@ -146,13 +170,27 @@ public function testNoInstructionsReturnedFromLlm() 'model' => 'gpt-4o', 'base_url' => 'https://api.openai.com', ]; - file_put_contents($this->runwayCredsFile, json_encode($creds)); $this->setInput([ - 'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info' + 'desc', + 'mysql', + 'latte', + 'y', + 'y', + 'flight/lib', + 'Docker', + '2', + 'y', + 'context info' ]); $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([ + [ + 'runway' => ['ai' => $creds] + ] + ]) ->onlyMethods(['callLlmApi']) ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); $cmd->expects($this->once()) ->method('callLlmApi') ->willReturn(json_encode([ @@ -162,12 +200,10 @@ public function testNoInstructionsReturnedFromLlm() ])); $app = $this->newApp($cmd); $result = $app->handle([ - 'runway', 'ai:generate-instructions', - '--creds-file=' . $this->runwayCredsFile, - '--base-dir=' . $this->baseDir + 'runway', + 'ai:generate-instructions', ]); $this->assertSame(1, $result); - $this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md'); } public function testLlmApiCallFails() @@ -177,23 +213,83 @@ public function testLlmApiCallFails() 'model' => 'gpt-4o', 'base_url' => 'https://api.openai.com', ]; - file_put_contents($this->runwayCredsFile, json_encode($creds)); $this->setInput([ - 'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info' + 'desc', + 'mysql', + 'latte', + 'y', + 'y', + 'flight/lib', + 'Docker', + '2', + 'y', + 'context info' ]); $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([ + [ + 'runway' => ['ai' => $creds] + ] + ]) ->onlyMethods(['callLlmApi']) ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); $cmd->expects($this->once()) ->method('callLlmApi') ->willReturn(false); $app = $this->newApp($cmd); $result = $app->handle([ - 'runway', 'ai:generate-instructions', - '--creds-file=' . $this->runwayCredsFile, - '--base-dir=' . $this->baseDir + 'runway', + 'ai:generate-instructions', ]); $this->assertSame(1, $result); - $this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md'); + } + + public function testUsesDeprecatedConfigFile() + { + $creds = [ + 'ai' => [ + 'api_key' => 'key', + 'model' => 'gpt-4o', + 'base_url' => 'https://api.openai.com', + ] + ]; + $configFile = $this->baseDir . 'old-config.json'; + file_put_contents($configFile, json_encode($creds)); + $this->setInput([ + 'desc', + 'mysql', + 'latte', + 'y', + 'y', + 'flight/lib', + 'Docker', + '2', + 'y', + 'context info' + ]); + $mockInstructions = "# Project Instructions\n\nUse MySQL, Latte, Docker."; + // runway key is MISSING from config to trigger deprecated logic + $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['callLlmApi']) + ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); + $cmd->expects($this->once()) + ->method('callLlmApi') + ->willReturn(json_encode([ + 'choices' => [ + ['message' => ['content' => $mockInstructions]] + ] + ])); + $app = $this->newApp($cmd); + $result = $app->handle([ + 'runway', + 'ai:generate-instructions', + '--config-file=' . $configFile + ]); + $this->assertSame(0, $result); + $this->assertStringContainsString('The --config-file option is deprecated', file_get_contents(self::$ou)); + $this->assertFileExists($this->baseDir . '.github/copilot-instructions.md'); } } diff --git a/tests/commands/AiInitCommandTest.php b/tests/commands/AiInitCommandTest.php index 1e7dd51..cb46c55 100644 --- a/tests/commands/AiInitCommandTest.php +++ b/tests/commands/AiInitCommandTest.php @@ -13,9 +13,6 @@ class AiInitCommandTest extends TestCase { protected static $in; protected static $ou; - protected $baseDir; - protected $runwayCredsFile; - protected $gitignoreFile; public function setUp(): void { @@ -23,15 +20,6 @@ public function setUp(): void self::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt'; file_put_contents(self::$in, ''); file_put_contents(self::$ou, ''); - $this->baseDir = getcwd() . DIRECTORY_SEPARATOR; - $this->runwayCredsFile = __DIR__ . DIRECTORY_SEPARATOR . 'dummy-creds-' . uniqid('', true) . '.json'; - $this->gitignoreFile = __DIR__ . DIRECTORY_SEPARATOR . 'dummy-gitignore-' . uniqid('', true); - if (file_exists($this->runwayCredsFile)) { - unlink($this->runwayCredsFile); - } - if (file_exists($this->gitignoreFile)) { - unlink($this->gitignoreFile); - } } public function tearDown(): void @@ -42,24 +30,15 @@ public function tearDown(): void if (file_exists(self::$ou)) { unlink(self::$ou); } - if (file_exists($this->runwayCredsFile)) { - if (is_dir($this->runwayCredsFile)) { - rmdir($this->runwayCredsFile); - } else { - unlink($this->runwayCredsFile); - } - } - if (file_exists($this->gitignoreFile)) { - unlink($this->gitignoreFile); - } } - protected function newApp(): Application + protected function newApp($command): Application { $app = new Application('test', '0.0.1', function ($exitCode) { return $exitCode; }); $app->io(new Interactor(self::$in, self::$ou)); + $app->add($command); return $app; } @@ -68,135 +47,55 @@ protected function setInput(array $lines): void file_put_contents(self::$in, implode("\n", $lines) . "\n"); } - public function testInitCreatesCredsAndGitignore() + public function testInitSavesCreds() { $this->setInput([ - '1', // provider + '1', // provider (openai) '', // accept default base url 'test-key', // api key '', // accept default model ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); - $this->assertSame(0, $result); - $this->assertFileExists($this->runwayCredsFile); - $creds = json_decode(file_get_contents($this->runwayCredsFile), true); - $this->assertSame('openai', $creds['provider']); - $this->assertSame('test-key', $creds['api_key']); - $this->assertSame('gpt-4o', $creds['model']); - $this->assertSame('https://api.openai.com', $creds['base_url']); - $this->assertFileExists($this->gitignoreFile); - $this->assertStringContainsString(basename($this->runwayCredsFile), file_get_contents($this->gitignoreFile)); - } - - public function testInitWithExistingCredsNoOverwrite() - { - file_put_contents($this->runwayCredsFile, '{}'); - $this->setInput([ - 'n', // do not overwrite - ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); + $cmd = $this->getMockBuilder(AiInitCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['setRunwayConfigValue']) + ->getMock(); + $cmd->expects($this->once()) + ->method('setRunwayConfigValue') + ->with('ai', [ + 'provider' => 'openai', + 'api_key' => 'test-key', + 'model' => 'gpt-5', + 'base_url' => 'https://api.openai.com', + ]); + $app = $this->newApp($cmd); + $result = $app->handle(['runway', 'ai:init']); $this->assertSame(0, $result); - $this->assertSame('{}', file_get_contents($this->runwayCredsFile)); + $this->assertStringContainsString('Credentials saved', file_get_contents(self::$ou)); } - public function testInitWithExistingCredsOverwrite() + public function testInitWithGrokProvider() { - file_put_contents($this->runwayCredsFile, '{}'); $this->setInput([ - 'y', // overwrite - '2', // provider + '2', // provider (grok) '', // accept default base url 'grok-key', // api key '', // accept default model ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); + $cmd = $this->getMockBuilder(AiInitCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['setRunwayConfigValue']) + ->getMock(); + $cmd->expects($this->once()) + ->method('setRunwayConfigValue') + ->with('ai', [ + 'provider' => 'grok', + 'api_key' => 'grok-key', + 'model' => 'grok-4.1-fast-non-reasoning', + 'base_url' => 'https://api.x.ai', + ]); + $app = $this->newApp($cmd); + $result = $app->handle(['runway', 'ai:init']); $this->assertSame(0, $result); - $creds = json_decode(file_get_contents($this->runwayCredsFile), true); - $this->assertSame('grok', $creds['provider']); - $this->assertSame('grok-key', $creds['api_key']); - $this->assertSame('grok-3-beta', $creds['model']); - $this->assertSame('https://api.x.ai', $creds['base_url']); - } - - public function testEmptyApiKeyPromptsAgain() - { - $this->setInput([ - '1', - '', // accept default base url - '', // empty api key, should error and exit - ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); - $this->assertSame(1, $result); - $this->assertFileDoesNotExist($this->runwayCredsFile); - } - - public function testEmptyModelPrompts() - { - $this->setInput([ - '1', - '', - 'key', - '', // accept default model (should use default) - ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); - $this->assertSame(0, $result); - $creds = json_decode(file_get_contents($this->runwayCredsFile), true); - $this->assertSame('gpt-4o', $creds['model']); - } - - public function testGitignoreAlreadyHasCreds() - { - file_put_contents($this->gitignoreFile, basename($this->runwayCredsFile) . "\n"); - $this->setInput([ - '1', - '', - 'key', - '', - ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); - $this->assertSame(0, $result); - $this->assertFileExists($this->gitignoreFile); - $lines = file($this->gitignoreFile, FILE_IGNORE_NEW_LINES); - $this->assertContains(basename($this->runwayCredsFile), $lines); - $this->assertCount(1, array_filter($lines, function ($l) { - return trim($l) === basename($this->runwayCredsFile); - })); } public function testInitWithClaudeProvider() @@ -207,44 +106,41 @@ public function testInitWithClaudeProvider() 'claude-key', // api key '', // accept default model ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); + $cmd = $this->getMockBuilder(AiInitCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['setRunwayConfigValue']) + ->getMock(); + $cmd->expects($this->once()) + ->method('setRunwayConfigValue') + ->with('ai', [ + 'provider' => 'claude', + 'api_key' => 'claude-key', + 'model' => 'claude-sonnet-4-5', + 'base_url' => 'https://api.anthropic.com', + ]); + $app = $this->newApp($cmd); + $result = $app->handle(['runway', 'ai:init']); $this->assertSame(0, $result); - $creds = json_decode(file_get_contents($this->runwayCredsFile), true); - $this->assertSame('claude', $creds['provider']); - $this->assertSame('claude-key', $creds['api_key']); - $this->assertSame('claude-3-opus', $creds['model']); - $this->assertSame('https://api.anthropic.com', $creds['base_url']); } - public function testAddsCredsFileToExistingGitignoreIfMissing() + public function testEmptyApiKeyFails() { - // .gitignore exists but does not contain creds file - file_put_contents($this->gitignoreFile, "vendor\nnode_modules\n.DS_Store\n"); $this->setInput([ - '1', // provider + '1', '', // accept default base url - 'test-key', // api key - '', // accept default model - ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); - $this->assertSame(0, $result); - $lines = file($this->gitignoreFile, FILE_IGNORE_NEW_LINES); - $this->assertContains(basename($this->runwayCredsFile), $lines); - $this->assertCount(1, array_filter($lines, function ($l) { - return trim($l) === basename($this->runwayCredsFile); - })); + '', // empty api key + ]); + $cmd = $this->getMockBuilder(AiInitCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['setRunwayConfigValue']) + ->getMock(); + $cmd->expects($this->never()) + ->method('setRunwayConfigValue'); + $app = $this->newApp($cmd); + $result = $app->handle(['runway', 'ai:init']); + // Since $io->error(..., true) exits, Ahc\Cli will return the exit code. + // If it exits with 1, it should be 1. + $this->assertSame(1, $result); } public function testInvalidBaseUrlFails() @@ -253,14 +149,14 @@ public function testInvalidBaseUrlFails() '1', // provider 'not-a-valid-url', // invalid base url ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); + $cmd = $this->getMockBuilder(AiInitCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['setRunwayConfigValue']) + ->getMock(); + $cmd->expects($this->never()) + ->method('setRunwayConfigValue'); + $app = $this->newApp($cmd); + $result = $app->handle(['runway', 'ai:init']); $this->assertSame(1, $result); - $this->assertFileDoesNotExist($this->runwayCredsFile); } } diff --git a/tests/commands/ControllerCommandTest.php b/tests/commands/ControllerCommandTest.php index 510aaa2..c19b312 100644 --- a/tests/commands/ControllerCommandTest.php +++ b/tests/commands/ControllerCommandTest.php @@ -48,7 +48,7 @@ public function tearDown(): void protected function newApp(string $name, string $version = '') { - $app = @new Application($name, $version ?: '0.0.1', fn () => false); + $app = @new Application($name, $version ?: '0.0.1', fn() => false); return @$app->io(new Interactor(static::$in, static::$ou)); } @@ -56,10 +56,10 @@ protected function newApp(string $name, string $version = '') public function testConfigAppRootNotSet(): void { $app = $this->newApp('test', '0.0.1'); - $app->add(new ControllerCommand([])); + $app->add(new ControllerCommand(['runway' => ['something' => '']])); @$app->handle(['runway', 'make:controller', 'Test']); - $this->assertStringContainsString('app_root not set in .runway-config.json', file_get_contents(static::$ou)); + $this->assertStringContainsString('app_root not set in app/config/config.php', file_get_contents(static::$ou)); } public function testControllerAlreadyExists(): void @@ -67,7 +67,7 @@ public function testControllerAlreadyExists(): void $app = $this->newApp('test', '0.0.1'); mkdir(__DIR__ . '/controllers/'); file_put_contents(__DIR__ . '/controllers/TestController.php', 'add(new ControllerCommand(['app_root' => 'tests/commands/'])); + $app->add(new ControllerCommand(['runway' => ['app_root' => 'tests/commands/']])); $app->handle(['runway', 'make:controller', 'Test']); $this->assertStringContainsString('TestController already exists.', file_get_contents(static::$ou)); @@ -76,7 +76,7 @@ public function testControllerAlreadyExists(): void public function testCreateController(): void { $app = $this->newApp('test', '0.0.1'); - $app->add(new ControllerCommand(['app_root' => 'tests/commands/'])); + $app->add(new ControllerCommand(['runway' => ['app_root' => 'tests/commands/']])); $app->handle(['runway', 'make:controller', 'Test']); $this->assertFileExists(__DIR__ . '/controllers/TestController.php'); diff --git a/tests/commands/RouteCommandTest.php b/tests/commands/RouteCommandTest.php index 8683871..11e39ce 100644 --- a/tests/commands/RouteCommandTest.php +++ b/tests/commands/RouteCommandTest.php @@ -23,6 +23,7 @@ public function setUp(): void static::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt'; file_put_contents(static::$in, ''); file_put_contents(static::$ou, ''); + $_SERVER = []; $_REQUEST = []; Flight::init(); @@ -91,21 +92,22 @@ protected function removeColors(string $str): string public function testConfigIndexRootNotSet(): void { $app = @$this->newApp('test', '0.0.1'); - $app->add(new RouteCommand([])); + $app->add(new RouteCommand(['runway' => ['something' => 'else']])); @$app->handle(['runway', 'routes']); - $this->assertStringContainsString('index_root not set in .runway-config.json', file_get_contents(static::$ou)); + $this->assertStringContainsString('index_root not set in app/config/config.php', file_get_contents(static::$ou)); } public function testGetRoutes(): void { $app = @$this->newApp('test', '0.0.1'); $this->createIndexFile(); - $app->add(new RouteCommand(['index_root' => 'tests/commands/index.php'])); + $app->add(new RouteCommand(['runway' => ['index_root' => 'tests/commands/index.php']])); @$app->handle(['runway', 'routes']); $this->assertStringContainsString('Routes', file_get_contents(static::$ou)); $expected = <<<'output' + Routes +---------+--------------------+-------+----------+----------------+ | Pattern | Methods | Alias | Streamed | Middleware | +---------+--------------------+-------+----------+----------------+ @@ -113,7 +115,7 @@ public function testGetRoutes(): void | /post | POST, OPTIONS | | No | Closure | | /delete | DELETE, OPTIONS | | No | - | | /put | PUT, OPTIONS | | No | - | - | /patch | PATCH, OPTIONS | | No | Bad Middleware | + | /patch | PATCH, OPTIONS | | No | SomeMiddleware | +---------+--------------------+-------+----------+----------------+ output; // phpcs:ignore @@ -127,12 +129,13 @@ public function testGetPostRoute(): void { $app = @$this->newApp('test', '0.0.1'); $this->createIndexFile(); - $app->add(new RouteCommand(['index_root' => 'tests/commands/index.php'])); + $app->add(new RouteCommand(['runway' => ['index_root' => 'tests/commands/index.php']])); @$app->handle(['runway', 'routes', '--post']); $this->assertStringContainsString('Routes', file_get_contents(static::$ou)); $expected = <<<'output' + Routes +---------+---------------+-------+----------+------------+ | Pattern | Methods | Alias | Streamed | Middleware | +---------+---------------+-------+----------+------------+ @@ -140,6 +143,8 @@ public function testGetPostRoute(): void +---------+---------------+-------+----------+------------+ output; // phpcs:ignore + $expected = str_replace(["\r\n", "\\n", "\n"], ["\n", "", "\n"], $expected); + $this->assertStringContainsString( $expected, $this->removeColors(file_get_contents(static::$ou))