diff --git a/.env.example b/.env.example index 3d45f76..0af6df8 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,19 @@ -# Database Configuration -# These environment variables override values in config/config.ini -# Use format: SECTION_KEY (e.g., DATABASE_HOST, DATABASE_USERNAME, etc.) -# -# For Docker Compose, these are set automatically -# For local development, set these as needed to override config.ini - # Database settings DATABASE_HOST=127.0.0.1 DATABASE_PORT=3306 DATABASE_DATABASE=f1_db DATABASE_USERNAME=root DATABASE_PASSWORD= +DATABASE_CHARSET=utf8mb4 +DATABASE_COLLATION=utf8mb4_general_ci +DATABASE_DRIVER=mysql # Application settings +APP_NAME="F1 Management API" APP_ENV=development APP_DEBUG=true + +# Log settings +LOG_LEVEL=debug +LOG_PATH=logs/app.log +LOG_CHANNEL=app diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 8d4b46f..794466f 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -5,6 +5,7 @@ on: branches: - main - staging/** + - feature/** paths-ignore: - README.md push: diff --git a/.idea/I425-Team-Project.iml b/.idea/I425-Team-Project.iml index 9064f71..af6d931 100644 --- a/.idea/I425-Team-Project.iml +++ b/.idea/I425-Team-Project.iml @@ -2,9 +2,8 @@ - - + diff --git a/.idea/php.xml b/.idea/php.xml index 8ced45b..c9e89e7 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -14,7 +14,7 @@ - + @@ -157,6 +157,9 @@ + + + diff --git a/DEV.md b/DEV.md index d4ec58d..1caf054 100644 --- a/DEV.md +++ b/DEV.md @@ -72,22 +72,9 @@ Xdebug is configured in the container. Configure your IDE: ### Environment Variables -Configuration is managed via `/config/config.ini` file (created from `config.ini.example`): - -```ini -[database] -host = mariadb -port = 3306 -database = f1_db -username = f1_user -password = f1_password - -[app] -env = development -debug = true -``` +Configuration is managed via `.env` file at the project root: -**For Docker Compose**: Environment variables override config.ini values +**For Docker Compose**: ```env DATABASE_HOST=mariadb DATABASE_PORT=3306 @@ -95,17 +82,21 @@ DATABASE_DATABASE=f1_db DATABASE_USERNAME=f1_user DATABASE_PASSWORD=f1_password APP_ENV=development +APP_DEBUG=true ``` -**For local XAMPP development** (without Docker), update `/config/config.ini`: -```ini -[database] -host = 127.0.0.1 -username = root -password = +**For local XAMPP development** (without Docker): +```env +DATABASE_HOST=127.0.0.1 +DATABASE_PORT=3306 +DATABASE_DATABASE=f1_db +DATABASE_USERNAME=root +DATABASE_PASSWORD= +APP_ENV=development +APP_DEBUG=true ``` -The `Config` class (`config/config.php`) loads settings with environment variables taking precedence. +The `Config` class (`config/config.php`) loads settings from `.env` file using strongly-typed configuration classes. See `CONFIG_MIGRATION.md` for detailed documentation. ## Composer diff --git a/ARCHITECTURE_DIAGRAMS.md b/DOCKER.ARCHITECTURE.md similarity index 100% rename from ARCHITECTURE_DIAGRAMS.md rename to DOCKER.ARCHITECTURE.md diff --git a/README.md b/README.md index d579b53..a870cc5 100644 --- a/README.md +++ b/README.md @@ -31,15 +31,21 @@ Pages: [F1 Management API Documentation](https://jvspeed74.github.io/PHP-RESTful Ideally this project should be run in a Docker container, but due to time constraints, it was not fully implemented. May be added in the future. +#### Configuration: + +This project uses `.env` files for configuration with strongly-typed configuration classes. See [CONFIG_MIGRATION.md](CONFIG_MIGRATION.md) for details. + 1. **Dependencies:** Install PHP dependencies using `composer install`. -2. **Database:** +2. **Configuration:** + * Copy `.env.example` to `.env`: `cp .env.example .env` + * Update `.env` with your database and application settings +3. **Database:** * Set up a MariaDB instance. * Import the schema from `f1_db.sql`. - * Configure database connection details (typically via environment variables or a configuration file - not detailed - in provided context). -3. **Running the Application:** + * Configure database connection in `.env` file +4. **Running the Application:** * The application can be served using a local PHP development server (e.g., `php -S localhost:8000 -t public`). -4. **Running Tests:** +5. **Running Tests:** * Unit tests: `vendor/bin/pest` * Integration tests: Requires a running application and database. Configure Newman with the appropriate environment variables and run the Postman collection. diff --git a/composer.json b/composer.json index 48f3357..a877f42 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,8 @@ "license": "MIT", "autoload": { "psr-4": { - "App\\": "src/" + "App\\": "src/", + "Config\\": "config/" } }, "authors": [ @@ -32,7 +33,8 @@ "slim/http": "1.4.0", "monolog/monolog": "3.7.0", "illuminate/pagination": "v11.30.0", - "firebase/php-jwt": "6.10" + "firebase/php-jwt": "6.10", + "vlucas/phpdotenv": "^5.6" }, "require-dev": { "phpstan/phpstan": "2.0.1", diff --git a/composer.lock b/composer.lock index f940d19..1541ab7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b5a9a86d8bde2e9e9cfa773d2d5bf7e0", + "content-hash": "fdd3d507c89cd3a4dc61b48058d472c4", "packages": [ { "name": "brick/math", @@ -344,6 +344,68 @@ }, "time": "2023-12-01T16:26:39+00:00" }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, { "name": "illuminate/collections", "version": "v11.47.0", @@ -1273,6 +1335,81 @@ }, "time": "2023-06-29T14:08:47+00:00" }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -2819,6 +2956,90 @@ ], "time": "2025-12-04T18:11:45+00:00" }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" + }, { "name": "voku/portable-ascii", "version": "2.0.3", diff --git a/config/AppConfig.php b/config/AppConfig.php new file mode 100644 index 0000000..40c7cf8 --- /dev/null +++ b/config/AppConfig.php @@ -0,0 +1,58 @@ +name; + } + + public function getEnv(): string + { + return $this->env; + } + + public function isDebug(): bool + { + return $this->debug; + } + + public function isProduction(): bool + { + return $this->env === 'production'; + } + + public function isDevelopment(): bool + { + return $this->env === 'development'; + } +} diff --git a/config/Config.php b/config/Config.php new file mode 100644 index 0000000..a104841 --- /dev/null +++ b/config/Config.php @@ -0,0 +1,73 @@ +load(); + } + + // Initialize config objects + self::$databaseConfig = DatabaseConfig::fromEnv(); + self::$appConfig = AppConfig::fromEnv(); + self::$logConfig = LogConfig::fromEnv(); + + self::$initialized = true; + } + + /** + * Get database configuration + */ + public static function database(): DatabaseConfig + { + self::init(); + return self::$databaseConfig; + } + + /** + * Get application configuration + */ + public static function app(): AppConfig + { + self::init(); + return self::$appConfig; + } + + /** + * Get logging configuration + */ + public static function log(): LogConfig + { + self::init(); + return self::$logConfig; + } +} diff --git a/config/DatabaseConfig.php b/config/DatabaseConfig.php new file mode 100644 index 0000000..469c66d --- /dev/null +++ b/config/DatabaseConfig.php @@ -0,0 +1,81 @@ +host; + } + + public function getPort(): int + { + return $this->port; + } + + public function getDatabase(): string + { + return $this->database; + } + + public function getUsername(): string + { + return $this->username; + } + + public function getPassword(): string + { + return $this->password; + } + + public function getCharset(): string + { + return $this->charset; + } + + public function getCollation(): string + { + return $this->collation; + } + + public function getDriver(): string + { + return $this->driver; + } +} diff --git a/config/LogConfig.php b/config/LogConfig.php new file mode 100644 index 0000000..a5fca2f --- /dev/null +++ b/config/LogConfig.php @@ -0,0 +1,46 @@ +level; + } + + public function getPath(): string + { + return $this->path; + } + + public function getChannel(): string + { + return $this->channel; + } +} diff --git a/config/bootstrap.php b/config/bootstrap.php index 09d665e..683c693 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -2,8 +2,15 @@ declare(strict_types=1); -require __DIR__ . '/config.php'; +use Config\Config; + require __DIR__ . '/../vendor/autoload.php'; -// Initialize configuration from config/config.ini +// Load config classes +require __DIR__ . '/DatabaseConfig.php'; +require __DIR__ . '/AppConfig.php'; +require __DIR__ . '/LogConfig.php'; +require __DIR__ . '/Config.php'; + +// Initialize configuration from .env Config::init(); diff --git a/config/config.ini b/config/config.ini deleted file mode 100644 index 65b2345..0000000 --- a/config/config.ini +++ /dev/null @@ -1,23 +0,0 @@ -[database] -; Database connection settings for Docker development -; For local XAMPP, update host to 127.0.0.1 and username to root -host = 127.0.0.1 -port = 3306 -database = f1_db -username = root -password = f1_password -charset = utf8mb4 -collation = utf8mb4_general_ci -driver = mysql - -[app] -; Application settings -name = F1 Management API -env = development -debug = true - -[logging] -; Logging configuration -level = debug -path = logs/app.log -channel = app diff --git a/config/config.ini.example b/config/config.ini.example deleted file mode 100644 index 02cf340..0000000 --- a/config/config.ini.example +++ /dev/null @@ -1,25 +0,0 @@ -[database] -; Database connection settings -; Override with environment variables: DB_HOST, DB_PORT, etc. -host = mariadb -port = 3306 -database = f1_db -username = f1_user -password = f1_password -charset = utf8mb4 -collation = utf8mb4_general_ci -driver = mysql - -[app] -; Application settings -; Override with environment variables: APP_NAME, APP_ENV, etc. -name = F1 Management API -env = development -debug = true - -[logging] -; Logging configuration -; Override with environment variables: LOG_LEVEL, LOG_PATH, etc. -level = debug -path = logs/app.log -channel = app diff --git a/config/config.php b/config/config.php deleted file mode 100644 index bbdb0c7..0000000 --- a/config/config.php +++ /dev/null @@ -1,190 +0,0 @@ -> - * - * @var array>|null - */ - private static ?array $config = null; - - /** - * Initialize configuration from ini file and environment variables - */ - public static function init(): void - { - if (self::$config !== null) { - return; - } - - $configFile = __DIR__ . '/config.ini'; - - if (!file_exists($configFile)) { - throw new RuntimeException( - "Configuration file not found: {$configFile}\n" . - "Please copy config.ini.example to config.ini and update with your settings." - ); - } - - // Load configuration from INI file - $iniConfig = @parse_ini_file($configFile, true, INI_SCANNER_RAW); - - if ($iniConfig === false) { - throw new RuntimeException("Failed to parse configuration file: {$configFile}"); - } - - // Ensure structure is the expected nested array> - $normalized = []; - foreach ($iniConfig as $section => $values) { - if (!is_array($values)) { - continue; - } - $normalized[(string) $section] = []; - foreach ($values as $k => $v) { - // Only scalar or null values are expected from parse_ini_file; guard for safety - if (is_scalar($v) || $v === null) { - $normalized[(string) $section][(string) $k] = (string) $v; - } else { - // Fallback to empty string for non-scalar values (unexpected) - $normalized[(string) $section][(string) $k] = ''; - } - } - } - - self::$config = $normalized; - - // Override with environment variables (takes precedence) - self::overrideWithEnvironment(); - } - - /** - * Override configuration values with environment variables - * Supports nested keys using SECTION_KEY env var naming (e.g. DATABASE_HOST) - * - * @return void - */ - private static function overrideWithEnvironment(): void - { - if (self::$config === null) { - return; - } - - foreach (self::$config as $section => $values) { - // $values is array by construction - - foreach (array_keys($values) as $key) { - $envKey = self::getEnvironmentKeyName($section, $key); - - if (array_key_exists($envKey, $_ENV)) { - $val = $_ENV[$envKey]; - // Ensure we only convert scalars/null to string - if (!is_scalar($val) && $val !== null) { - $val = ''; - } - self::$config[$section][$key] = (string) $val; - } - } - } - } - - /** - * Generate environment variable name from section and key - * database.host -> DATABASE_HOST - * app.name -> APP_NAME - */ - private static function getEnvironmentKeyName(string $section, string $key): string - { - return strtoupper("{$section}_{$key}"); - } - - /** - * Get a configuration value - * - * @param string $key Configuration key in dot notation (e.g., "database.host") - * @param mixed $default Default value if key not found - * @return mixed - */ - public static function get(string $key, mixed $default = null): mixed - { - self::init(); - - $parts = explode('.', $key, 2); - - if (count($parts) !== 2) { - throw new InvalidArgumentException( - "Invalid configuration key format: {$key}. Use section.key format." - ); - } - - [$section, $subkey] = $parts; - - return self::$config[$section][$subkey] ?? $default; - } - - /** - * Get all configuration for a section - * - * @param string $section Section name - * @return array - */ - public static function getSection(string $section): array - { - self::init(); - - return self::$config[$section] ?? []; - } - - /** - * Get database configuration - * - * @return array - */ - public static function getDatabase(): array - { - return self::getSection('database'); - } - - /** - * Check if a configuration key exists - * - * @param string $key Configuration key in dot notation - * @return bool - */ - public static function has(string $key): bool - { - self::init(); - - $parts = explode('.', $key, 2); - - if (count($parts) !== 2) { - return false; - } - - [$section, $subkey] = $parts; - - return isset(self::$config[$section][$subkey]); - } - - /** - * Get all configuration - * - * @return array> - */ - public static function all(): array - { - self::init(); - - return self::$config ?? []; - } -} diff --git a/config/dependencies.php b/config/dependencies.php index 7a10794..c121eab 100644 --- a/config/dependencies.php +++ b/config/dependencies.php @@ -3,15 +3,13 @@ declare(strict_types=1); -use App\Authentication\JWTAuthenticator; +use Config\Config; use Illuminate\Database\Capsule\Manager; use Monolog\Formatter\LineFormatter; use Monolog\Handler\StreamHandler; use Monolog\Level; use Monolog\Logger; -use Psr\Http\Message\ResponseFactoryInterface; use Psr\Log\LoggerInterface; -use Slim\Psr7\Factory\ResponseFactory; return [ // Define Monolog logger as a service @@ -33,18 +31,18 @@ }, 'db' => function () { $capsule = new Manager(); - $dbConfig = Config::getDatabase(); + $dbConfig = Config::database(); $capsule->addConnection( [ - 'driver' => $dbConfig['driver'] ?? 'mysql', - 'host' => $dbConfig['host'] ?? '127.0.0.1', - 'port' => $dbConfig['port'] ?? 3306, - 'database' => $dbConfig['database'] ?? 'f1_db', - 'username' => $dbConfig['username'] ?? 'root', - 'password' => $dbConfig['password'] ?? '', - 'charset' => $dbConfig['charset'] ?? 'utf8mb4', - 'collation' => $dbConfig['collation'] ?? 'utf8mb4_general_ci', + 'driver' => $dbConfig->getDriver(), + 'host' => $dbConfig->getHost(), + 'port' => $dbConfig->getPort(), + 'database' => $dbConfig->getDatabase(), + 'username' => $dbConfig->getUsername(), + 'password' => $dbConfig->getPassword(), + 'charset' => $dbConfig->getCharset(), + 'collation' => $dbConfig->getCollation(), ], ); $capsule->setAsGlobal(); diff --git a/tests/Configuration/AppConfigTest.php b/tests/Configuration/AppConfigTest.php new file mode 100644 index 0000000..6c6c316 --- /dev/null +++ b/tests/Configuration/AppConfigTest.php @@ -0,0 +1,154 @@ +getName())->toBe('F1 Management API'); + expect($config->getEnv())->toBe('development'); + expect($config->isDebug())->toBeTrue(); + }); + + test('uses custom environment variable values when set', function () { + setTestEnv([ + 'APP_NAME' => 'Custom API', + 'APP_ENV' => 'production', + 'APP_DEBUG' => 'false', + ]); + + $config = AppConfig::fromEnv(); + + expect($config->getName())->toBe('Custom API'); + expect($config->getEnv())->toBe('production'); + expect($config->isDebug())->toBeFalse(); + }); + + test('converts debug string values to boolean correctly', function () { + setTestEnv(['APP_DEBUG' => '0']); + $config = AppConfig::fromEnv(); + expect($config->isDebug())->toBeFalse(); + + setTestEnv(['APP_DEBUG' => '1']); + $config = AppConfig::fromEnv(); + expect($config->isDebug())->toBeTrue(); + + setTestEnv(['APP_DEBUG' => 'yes']); + $config = AppConfig::fromEnv(); + expect($config->isDebug())->toBeTrue(); + + setTestEnv(['APP_DEBUG' => 'no']); + $config = AppConfig::fromEnv(); + expect($config->isDebug())->toBeFalse(); + }); + + test('defaults APP_DEBUG to true when not set', function () { + resetTestEnv(); + + $config = AppConfig::fromEnv(); + + expect($config->isDebug())->toBeTrue(); + }); + }); + + describe('getters', function () { + + test('getName() returns application name', function () { + setTestEnv(['APP_NAME' => 'My App']); + + $config = AppConfig::fromEnv(); + + expect($config->getName())->toBe('My App'); + }); + + test('getEnv() returns environment name', function () { + setTestEnv(['APP_ENV' => 'staging']); + + $config = AppConfig::fromEnv(); + + expect($config->getEnv())->toBe('staging'); + }); + + test('isDebug() returns debug flag as boolean', function () { + setTestEnv(['APP_DEBUG' => 'true']); + $config = AppConfig::fromEnv(); + expect($config->isDebug())->toBeTrue(); + + setTestEnv(['APP_DEBUG' => 'false']); + $config = AppConfig::fromEnv(); + expect($config->isDebug())->toBeFalse(); + }); + }); + + describe('environment detection', function () { + + test('isProduction() returns true when env is production', function () { + setTestEnv(['APP_ENV' => 'production']); + + $config = AppConfig::fromEnv(); + + expect($config->isProduction())->toBeTrue(); + expect($config->isDevelopment())->toBeFalse(); + }); + + test('isProduction() returns false when env is not production', function () { + setTestEnv(['APP_ENV' => 'development']); + + $config = AppConfig::fromEnv(); + + expect($config->isProduction())->toBeFalse(); + }); + + test('isDevelopment() returns true when env is development', function () { + setTestEnv(['APP_ENV' => 'development']); + + $config = AppConfig::fromEnv(); + + expect($config->isDevelopment())->toBeTrue(); + expect($config->isProduction())->toBeFalse(); + }); + + test('isDevelopment() returns false when env is not development', function () { + setTestEnv(['APP_ENV' => 'production']); + + $config = AppConfig::fromEnv(); + + expect($config->isDevelopment())->toBeFalse(); + }); + + test('neither isProduction nor isDevelopment when env is staging', function () { + setTestEnv(['APP_ENV' => 'staging']); + + $config = AppConfig::fromEnv(); + + expect($config->isProduction())->toBeFalse(); + expect($config->isDevelopment())->toBeFalse(); + }); + }); +}); diff --git a/tests/Configuration/ConfigTest.php b/tests/Configuration/ConfigTest.php new file mode 100644 index 0000000..bf63da2 --- /dev/null +++ b/tests/Configuration/ConfigTest.php @@ -0,0 +1,260 @@ +getProperty('initialized'); + $initializedProperty->setAccessible(true); + $initializedProperty->setValue(null, false); + + $databaseConfigProperty = $reflection->getProperty('databaseConfig'); + $databaseConfigProperty->setAccessible(true); + $databaseConfigProperty->setValue(null, null); + + $appConfigProperty = $reflection->getProperty('appConfig'); + $appConfigProperty->setAccessible(true); + $appConfigProperty->setValue(null, null); + + $logConfigProperty = $reflection->getProperty('logConfig'); + $logConfigProperty->setAccessible(true); + $logConfigProperty->setValue(null, null); + }); + + afterEach(function () { + // Restore original $_ENV to prevent test contamination + $_ENV = $GLOBALS['_ENV_BACKUP']; + + // Reset Config's static state again after each test + $reflection = new \ReflectionClass(\Config\Config::class); + $initializedProperty = $reflection->getProperty('initialized'); + $initializedProperty->setAccessible(true); + $initializedProperty->setValue(null, false); + + $databaseConfigProperty = $reflection->getProperty('databaseConfig'); + $databaseConfigProperty->setAccessible(true); + $databaseConfigProperty->setValue(null, null); + + $appConfigProperty = $reflection->getProperty('appConfig'); + $appConfigProperty->setAccessible(true); + $appConfigProperty->setValue(null, null); + + $logConfigProperty = $reflection->getProperty('logConfig'); + $logConfigProperty->setAccessible(true); + $logConfigProperty->setValue(null, null); + }); + + describe('initialization', function () { + + test('init() can be called without throwing exception', function () { + resetTestEnv(); + + // Simply call init() and if it doesn't throw, test passes + \Config\Config::init(); + expect(true)->toBeTrue(); + }); + + test('init() is idempotent - calling multiple times does not reinitialize', function () { + resetTestEnv(); + + setTestEnv(['APP_NAME' => 'First Call']); + \Config\Config::init(); + $firstConfig = \Config\Config::app(); + + // Change the environment variable + setTestEnv(['APP_NAME' => 'Second Call']); + // Call init again - it should not reload because it's already initialized + \Config\Config::init(); + $secondConfig = \Config\Config::app(); + + // Both should return the same config object from the first call + expect($firstConfig->getName())->toBe('First Call'); + expect($secondConfig->getName())->toBe('First Call'); + expect($firstConfig)->toBe($secondConfig); + }); + }); + + describe('database() getter', function () { + + test('returns DatabaseConfig instance', function () { + resetTestEnv(); + + $config = \Config\Config::database(); + + expect($config)->toBeInstanceOf(DatabaseConfig::class); + }); + + test('database() automatically calls init() if not initialized', function () { + resetTestEnv(); + + // Call database() without explicitly calling init() + $config = \Config\Config::database(); + + expect($config)->toBeInstanceOf(DatabaseConfig::class); + expect($config->getHost())->toBe('127.0.0.1'); + }); + + test('returns the same instance across multiple calls', function () { + resetTestEnv(); + + $first = \Config\Config::database(); + $second = \Config\Config::database(); + + expect($first)->toBe($second); + }); + + test('database config reflects environment variables', function () { + setTestEnv(['DATABASE_HOST' => 'testhost']); + + $config = \Config\Config::database(); + + expect($config->getHost())->toBe('testhost'); + }); + }); + + describe('app() getter', function () { + + test('returns AppConfig instance', function () { + resetTestEnv(); + + $config = \Config\Config::app(); + + expect($config)->toBeInstanceOf(AppConfig::class); + }); + + test('app() automatically calls init() if not initialized', function () { + resetTestEnv(); + + // Call app() without explicitly calling init() + $config = \Config\Config::app(); + + expect($config)->toBeInstanceOf(AppConfig::class); + expect($config->getName())->toBe('F1 Management API'); + }); + + test('returns the same instance across multiple calls', function () { + resetTestEnv(); + + $first = \Config\Config::app(); + $second = \Config\Config::app(); + + expect($first)->toBe($second); + }); + + test('app config reflects environment variables', function () { + setTestEnv(['APP_NAME' => 'Custom Application']); + + $config = \Config\Config::app(); + + expect($config->getName())->toBe('Custom Application'); + }); + }); + + describe('log() getter', function () { + + test('returns LogConfig instance', function () { + resetTestEnv(); + + $config = \Config\Config::log(); + + expect($config)->toBeInstanceOf(LogConfig::class); + }); + + test('log() automatically calls init() if not initialized', function () { + resetTestEnv(); + + // Call log() without explicitly calling init() + $config = \Config\Config::log(); + + expect($config)->toBeInstanceOf(LogConfig::class); + expect($config->getLevel())->toBe('debug'); + }); + + test('returns the same instance across multiple calls', function () { + resetTestEnv(); + + $first = \Config\Config::log(); + $second = \Config\Config::log(); + + expect($first)->toBe($second); + }); + + test('log config reflects environment variables', function () { + setTestEnv(['LOG_LEVEL' => 'critical']); + + $config = \Config\Config::log(); + + expect($config->getLevel())->toBe('critical'); + }); + }); + + describe('integration', function () { + + test('all three config getters return correct types', function () { + resetTestEnv(); + + $database = \Config\Config::database(); + $app = \Config\Config::app(); + $log = \Config\Config::log(); + + expect($database)->toBeInstanceOf(DatabaseConfig::class); + expect($app)->toBeInstanceOf(AppConfig::class); + expect($log)->toBeInstanceOf(LogConfig::class); + }); + + test('all configs are initialized together in one init() call', function () { + resetTestEnv(); + + \Config\Config::init(); + + $database = \Config\Config::database(); + $app = \Config\Config::app(); + $log = \Config\Config::log(); + + expect($database)->toBeInstanceOf(DatabaseConfig::class); + expect($app)->toBeInstanceOf(AppConfig::class); + expect($log)->toBeInstanceOf(LogConfig::class); + }); + + test('all getters use environment variables correctly', function () { + setTestEnv([ + 'APP_NAME' => 'Test API', + 'APP_ENV' => 'testing', + 'APP_DEBUG' => 'false', + 'DATABASE_HOST' => 'testdb', + 'DATABASE_PORT' => '5432', + 'LOG_LEVEL' => 'info', + 'LOG_CHANNEL' => 'test', + ]); + + $app = \Config\Config::app(); + $database = \Config\Config::database(); + $log = \Config\Config::log(); + + expect($app->getName())->toBe('Test API'); + expect($app->getEnv())->toBe('testing'); + expect($database->getHost())->toBe('testdb'); + expect($database->getPort())->toBe(5432); + expect($log->getLevel())->toBe('info'); + expect($log->getChannel())->toBe('test'); + }); + }); +}); diff --git a/tests/Configuration/DatabaseConfigTest.php b/tests/Configuration/DatabaseConfigTest.php new file mode 100644 index 0000000..bec3d27 --- /dev/null +++ b/tests/Configuration/DatabaseConfigTest.php @@ -0,0 +1,194 @@ +getHost())->toBe('127.0.0.1'); + expect($config->getPort())->toBe(3306); + expect($config->getDatabase())->toBe('f1_db'); + expect($config->getUsername())->toBe('root'); + expect($config->getPassword())->toBe(''); + expect($config->getCharset())->toBe('utf8mb4'); + expect($config->getCollation())->toBe('utf8mb4_general_ci'); + expect($config->getDriver())->toBe('mysql'); + }); + + test('uses custom environment variable values when set', function () { + setTestEnv([ + 'DATABASE_HOST' => 'db.example.com', + 'DATABASE_PORT' => '5432', + 'DATABASE_DATABASE' => 'custom_db', + 'DATABASE_USERNAME' => 'admin', + 'DATABASE_PASSWORD' => 'secret123', + 'DATABASE_CHARSET' => 'utf8', + 'DATABASE_COLLATION' => 'utf8_general_ci', + 'DATABASE_DRIVER' => 'pgsql', + ]); + + $config = DatabaseConfig::fromEnv(); + + expect($config->getHost())->toBe('db.example.com'); + expect($config->getPort())->toBe(5432); + expect($config->getDatabase())->toBe('custom_db'); + expect($config->getUsername())->toBe('admin'); + expect($config->getPassword())->toBe('secret123'); + expect($config->getCharset())->toBe('utf8'); + expect($config->getCollation())->toBe('utf8_general_ci'); + expect($config->getDriver())->toBe('pgsql'); + }); + + test('converts port to integer type', function () { + setTestEnv(['DATABASE_PORT' => '3307']); + + $config = DatabaseConfig::fromEnv(); + + expect($config->getPort())->toBe(3307)->and($config->getPort())->toBeInt(); + }); + + test('handles string port conversion with leading zeros', function () { + setTestEnv(['DATABASE_PORT' => '08888']); + + $config = DatabaseConfig::fromEnv(); + + expect($config->getPort())->toBe(8888)->and($config->getPort())->toBeInt(); + }); + + test('allows empty password by default', function () { + resetTestEnv(); + + $config = DatabaseConfig::fromEnv(); + + expect($config->getPassword())->toBe(''); + }); + }); + + describe('getters', function () { + + test('getHost() returns database host', function () { + setTestEnv(['DATABASE_HOST' => 'localhost']); + + $config = DatabaseConfig::fromEnv(); + + expect($config->getHost())->toBe('localhost'); + }); + + test('getPort() returns port as integer', function () { + setTestEnv(['DATABASE_PORT' => '3306']); + + $config = DatabaseConfig::fromEnv(); + + expect($config->getPort())->toBe(3306)->and($config->getPort())->toBeInt(); + }); + + test('getDatabase() returns database name', function () { + setTestEnv(['DATABASE_DATABASE' => 'test_db']); + + $config = DatabaseConfig::fromEnv(); + + expect($config->getDatabase())->toBe('test_db'); + }); + + test('getUsername() returns database username', function () { + setTestEnv(['DATABASE_USERNAME' => 'testuser']); + + $config = DatabaseConfig::fromEnv(); + + expect($config->getUsername())->toBe('testuser'); + }); + + test('getPassword() returns database password', function () { + setTestEnv(['DATABASE_PASSWORD' => 'mypassword']); + + $config = DatabaseConfig::fromEnv(); + + expect($config->getPassword())->toBe('mypassword'); + }); + + test('getCharset() returns character set', function () { + setTestEnv(['DATABASE_CHARSET' => 'latin1']); + + $config = DatabaseConfig::fromEnv(); + + expect($config->getCharset())->toBe('latin1'); + }); + + test('getCollation() returns collation', function () { + setTestEnv(['DATABASE_COLLATION' => 'latin1_general_ci']); + + $config = DatabaseConfig::fromEnv(); + + expect($config->getCollation())->toBe('latin1_general_ci'); + }); + + test('getDriver() returns database driver', function () { + setTestEnv(['DATABASE_DRIVER' => 'postgresql']); + + $config = DatabaseConfig::fromEnv(); + + expect($config->getDriver())->toBe('postgresql'); + }); + }); + + describe('default values', function () { + + test('default charset is utf8mb4', function () { + resetTestEnv(); + + $config = DatabaseConfig::fromEnv(); + + expect($config->getCharset())->toBe('utf8mb4'); + }); + + test('default collation is utf8mb4_general_ci', function () { + resetTestEnv(); + + $config = DatabaseConfig::fromEnv(); + + expect($config->getCollation())->toBe('utf8mb4_general_ci'); + }); + + test('default driver is mysql', function () { + resetTestEnv(); + + $config = DatabaseConfig::fromEnv(); + + expect($config->getDriver())->toBe('mysql'); + }); + + test('default port is 3306', function () { + resetTestEnv(); + + $config = DatabaseConfig::fromEnv(); + + expect($config->getPort())->toBe(3306); + }); + }); +}); diff --git a/tests/Configuration/LogConfigTest.php b/tests/Configuration/LogConfigTest.php new file mode 100644 index 0000000..e1fada9 --- /dev/null +++ b/tests/Configuration/LogConfigTest.php @@ -0,0 +1,142 @@ +getLevel())->toBe('debug'); + expect($config->getPath())->toBe('logs/app.log'); + expect($config->getChannel())->toBe('app'); + }); + + test('uses custom environment variable values when set', function () { + setTestEnv([ + 'LOG_LEVEL' => 'error', + 'LOG_PATH' => 'storage/logs/custom.log', + 'LOG_CHANNEL' => 'custom_channel', + ]); + + $config = LogConfig::fromEnv(); + + expect($config->getLevel())->toBe('error'); + expect($config->getPath())->toBe('storage/logs/custom.log'); + expect($config->getChannel())->toBe('custom_channel'); + }); + + test('allows partial environment variable overrides', function () { + setTestEnv([ + 'LOG_LEVEL' => 'warning', + // LOG_PATH and LOG_CHANNEL use defaults + ]); + + $config = LogConfig::fromEnv(); + + expect($config->getLevel())->toBe('warning'); + expect($config->getPath())->toBe('logs/app.log'); + expect($config->getChannel())->toBe('app'); + }); + }); + + describe('getters', function () { + test('getLevel() returns log level', function () { + setTestEnv(['LOG_LEVEL' => 'info']); + + $config = LogConfig::fromEnv(); + + expect($config->getLevel())->toBe('info'); + }); + + test('getPath() returns log file path', function () { + setTestEnv(['LOG_PATH' => 'var/logs/application.log']); + + $config = LogConfig::fromEnv(); + + expect($config->getPath())->toBe('var/logs/application.log'); + }); + + test('getChannel() returns log channel', function () { + setTestEnv(['LOG_CHANNEL' => 'database']); + + $config = LogConfig::fromEnv(); + + expect($config->getChannel())->toBe('database'); + }); + }); + + describe('default values', function () { + test('default level is debug', function () { + resetTestEnv(); + + $config = LogConfig::fromEnv(); + + expect($config->getLevel())->toBe('debug'); + }); + + test('default path is logs/app.log', function () { + resetTestEnv(); + + $config = LogConfig::fromEnv(); + + expect($config->getPath())->toBe('logs/app.log'); + }); + + test('default channel is app', function () { + resetTestEnv(); + + $config = LogConfig::fromEnv(); + + expect($config->getChannel())->toBe('app'); + }); + }); + + describe('environment variable variations', function () { + test('supports various log levels', function () { + $levels = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency']; + + foreach ($levels as $level) { + setTestEnv(['LOG_LEVEL' => $level]); + $config = LogConfig::fromEnv(); + expect($config->getLevel())->toBe($level); + } + }); + + test('supports custom log paths with different formats', function () { + setTestEnv(['LOG_PATH' => '/var/log/app.log']); + $config = LogConfig::fromEnv(); + expect($config->getPath())->toBe('/var/log/app.log'); + + setTestEnv(['LOG_PATH' => './logs/debug.log']); + $config = LogConfig::fromEnv(); + expect($config->getPath())->toBe('./logs/debug.log'); + + setTestEnv(['LOG_PATH' => '/tmp/error.log']); + $config = LogConfig::fromEnv(); + expect($config->getPath())->toBe('/tmp/error.log'); + }); + }); +}); diff --git a/tests/Pest.php b/tests/Pest.php index f66ad9c..a87fbe7 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -15,6 +15,22 @@ // pest()->extend(Tests\TestCase::class)->in('Feature'); +/* +|-------------------------------------------------------------------------- +| Bootstrap & Autoloading +|-------------------------------------------------------------------------- +| +| Load the application bootstrap to ensure all classes are available +| +*/ +require __DIR__ . '/../vendor/autoload.php'; + +// Load config classes without initializing (we'll do that in tests) +require __DIR__ . '/../config/DatabaseConfig.php'; +require __DIR__ . '/../config/AppConfig.php'; +require __DIR__ . '/../config/LogConfig.php'; +require __DIR__ . '/../config/Config.php'; + /* |-------------------------------------------------------------------------- | Expectations @@ -43,7 +59,35 @@ | */ -function something() +/** + * Store original $_ENV state before tests + */ +$GLOBALS['_ENV_BACKUP'] = $_ENV; + +/** + * Set environment variables for tests + * + * @param array $vars Key-value pairs to set in $_ENV + */ +function setTestEnv(array $vars): void +{ + foreach ($vars as $key => $value) { + $_ENV[$key] = $value; + } +} + +/** + * Reset $_ENV to original state before tests + */ +function resetTestEnv(): void +{ + $_ENV = $GLOBALS['_ENV_BACKUP']; +} + +/** + * Get a specific environment variable for testing + */ +function getTestEnv(string $key, mixed $default = null): mixed { - // .. + return $_ENV[$key] ?? $default; }