diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f33b075..6b9135c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,7 +19,7 @@ env: fail-fast: true # PHP extensions required by Composer - EXTENSIONS: json, mbstring, pdo, pdo_mysql + EXTENSIONS: json, mbstring, pcov, pdo, pdo_mysql permissions: { } jobs: @@ -62,9 +62,9 @@ jobs: run: | composer cs -# - name: "PHPStan" -# run: | -# composer analyze + - name: "PHPStan" + run: | + composer analyze unit-tests: needs: phpcs @@ -114,16 +114,17 @@ jobs: shell: bash run: | cp config/.env.ci .env + mkdir -p tests/_output/coverage/ - - name: "Run Unit Tests" + - name: "Run Migrations" if: always() run: | - composer test-unit + composer migrate - - name: "Run Migrations" + - name: "Run Unit Tests" if: always() run: | - composer migrate + composer test-unit-coverage - name: SonarCloud Scan uses: SonarSource/sonarqube-scan-action@v5 @@ -140,3 +141,4 @@ jobs: -Dsonar.sourceEncoding=UTF-8 -Dsonar.language=php -Dsonar.tests=tests/ + -Dsonar.php.coverage.reportPaths=tests/_output/cov.xml diff --git a/.gitignore b/.gitignore index 28ac9e3..37158dd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ vendor composer.lock .env +tests/_output diff --git a/composer.json b/composer.json index a4a2174..819569a 100644 --- a/composer.json +++ b/composer.json @@ -58,6 +58,8 @@ "cs": "vendor/bin/phpcs --standard=phpcs.xml", "cs-fix": "vendor/bin/phpcbf --standard=phpcs.xml", "migrate": "vendor/bin/phinx migrate", - "test-unit": "vendor/bin/phpunit -c phpunit.xml.dist --display-all-issues" + "test-unit": "vendor/bin/phpunit -c phpunit.xml.dist --display-all-issues", + "test-unit-coverage": "vendor/bin/phpunit -c phpunit.xml.dist --coverage-clover tests/_output/cov.xml --display-all-issues", + "test-unit-coverage-html": "vendor/bin/phpunit -c phpunit.xml.dist --coverage-html tests/_output/coverage --display-all-issues" } } diff --git a/phpunit.php b/phpunit.php index 282dd75..9158029 100644 --- a/phpunit.php +++ b/phpunit.php @@ -1,5 +1,7 @@ @@ -7,7 +9,6 @@ # For the full copyright and license information, please view # the LICENSE file that was distributed with this source code. -declare(strict_types=1); ini_set('xdebug.mode', 'coverage'); diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..3ddc64f --- /dev/null +++ b/public/index.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + + +use Phalcon\Api\Domain\Services\Container; +use Phalcon\Api\Domain\Services\Environment\EnvManager; + +require dirname(__DIR__) . '/vendor/autoload.php'; + +$container = new Container(); + +///** @var array $providers */ +//$providers = require_once EnvManager::appPath('/config/providers.php'); + +//$application = new Api($container, $providers); +// +//$application->setup()->run(); diff --git a/src/.gitkeep b/src/Action/.gitkeep similarity index 100% rename from src/.gitkeep rename to src/Action/.gitkeep diff --git a/src/Domain/Services/Container.php b/src/Domain/Services/Container.php new file mode 100644 index 0000000..6a8a620 --- /dev/null +++ b/src/Domain/Services/Container.php @@ -0,0 +1,248 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services; + +use Phalcon\Api\Domain\Services\Environment\EnvManager; +use Phalcon\Api\Domain\Services\Exceptions\InvalidConfigurationArguments; +use Phalcon\Cache\AdapterFactory; +use Phalcon\Cache\Cache; +use Phalcon\DataMapper\Pdo\Connection; +use Phalcon\Di\Di; +use Phalcon\Di\Service; +use Phalcon\Encryption\Security; +use Phalcon\Events\Manager as EventsManager; +use Phalcon\Filter\FilterFactory; +use Phalcon\Http\Message\Request; +use Phalcon\Http\Message\Response; +use Phalcon\Logger\Adapter\Stream; +use Phalcon\Logger\Logger; +use Phalcon\Mvc\Router; +use Phalcon\Storage\SerializerFactory; + +use function array_merge; +use function sprintf; + +class Container extends Di +{ + /** @var string */ + public const APPLICATION = 'application'; + /** @var string */ + public const CACHE = 'cache'; + /** @var string */ + public const CONNECTION = 'connection'; + /** @var string */ + public const EVENTS_MANAGER = 'eventsManager'; + /** @var string */ + public const FILTER = 'filter'; + /** @var string */ + public const LOGGER = 'logger'; + /** @var string */ + public const REQUEST = 'request'; + /** @var string */ + public const RESPONSE = 'response'; + /** @var string */ + public const ROUTER = 'router'; + /** @var string */ + public const SECURITY = 'security'; + /** @var string */ + public const TIME = 'time'; + + /** + * @throws InvalidConfigurationArguments + */ + public function __construct() + { + /** @var array $services */ + $services = $this->services; + + $this->services = array_merge( + [ + self::LOGGER => $this->getServiceLogger(), + self::CACHE => $this->getServiceCache(), + self::CONNECTION => $this->getServiceConnection(), + self::EVENTS_MANAGER => $this->getServiceEventsManger(), + self::FILTER => $this->getServiceFilter(), + self::REQUEST => $this->getServiceRequest(), + self::RESPONSE => $this->getServiceResponse(), + self::ROUTER => $this->getServiceRouter(), + self::SECURITY => $this->getServiceSecurity(), + ], + $services + ); + + parent::__construct(); + } + + /** + * @return Service + * @throws InvalidConfigurationArguments + */ + private function getServiceCache(): Service + { + $adapter = EnvManager::getString('CACHE_ADAPTER', 'redis'); + $options = EnvManager::getCacheOptions(); + return new Service( + function () use ($adapter, $options) { + return new Cache( + (new AdapterFactory(new SerializerFactory())) + ->newInstance($adapter, $options) + ); + }, + true + ); + } + + /** + * @return Service + */ + private function getServiceConnection(): Service + { + return new Service( + function () { + $dbname = EnvManager::getString('DB_NAME', 'phalcon'); + $host = EnvManager::getString('DB_HOST', 'rest-db'); + $password = EnvManager::getString('DB_PASSWORD', 'secret'); + $port = (int)EnvManager::get('DB_PORT', 3306); + $username = EnvManager::getString('DB_USER', 'phalcon'); + $encoding = 'utf8'; + $queries = ['SET NAMES utf8mb4']; + $dsn = sprintf( + "mysql:host=%s;dbname=%s;charset=%s;port=%s", + $host, + $dbname, + $encoding, + $port + ); + + return new Connection( + $dsn, + $username, + $password, + [], + $queries + ); + }, + true + ); + } + + /** + * @return Service + */ + private function getServiceEventsManger(): Service + { + return new Service( + function () { + $em = new EventsManager(); + $em->enablePriorities(true); + + return $em; + } + ); + } + + /** + * @return Service + */ + private function getServiceFilter(): Service + { + return new Service( + function () { + return (new FilterFactory())->newInstance(); + }, + true + ); + } + + /** + * @return Service + */ + private function getServiceLogger(): Service + { + return new Service( + function () { + $fileName = EnvManager::getString('USER_LOG_FILENAME', 'rest'); + $logPath = EnvManager::getString('USER_LOG_PATH', 'storage/logs'); + $logFile = EnvManager::appPath($logPath) + . '/' . $fileName . '.log'; + + return new Logger( + $fileName, + [ + 'main' => new Stream($logFile), + ] + ); + }, + true + ); + } + + /** + * @return Service + */ + private function getServiceRequest(): Service + { + return new Service( + [ + 'className' => Request::class, + ], + true + ); + } + + /** + * @return Service + */ + private function getServiceResponse(): Service + { + return new Service( + [ + 'className' => Response::class, + ], + true + ); + } + + /** + * @return Service + */ + private function getServiceRouter(): Service + { + return new Service( + [ + 'className' => Router::class, + 'arguments' => [ + [ + 'type' => 'parameter', + 'value' => false, + ] + ] + ], + true + ); + } + + /** + * @return Service + */ + private function getServiceSecurity(): Service + { + return new Service( + [ + 'className' => Security::class, + ], + true + ); + } +} diff --git a/src/Domain/Services/Environment/Adapter/AdapterInterface.php b/src/Domain/Services/Environment/Adapter/AdapterInterface.php new file mode 100644 index 0000000..48abb3e --- /dev/null +++ b/src/Domain/Services/Environment/Adapter/AdapterInterface.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\Environment\Adapter; + +use Phalcon\Api\Domain\Services\Environment\EnvManager; +use Phalcon\Api\Domain\Services\Exceptions\InvalidConfigurationArguments; + +/** + * Interface for Env adapters + * + * @phpstan-import-type TSettings from EnvManager + */ +interface AdapterInterface +{ + /** + * @param array $options + * + * @return TSettings + * @throws InvalidConfigurationArguments + */ + public function load(array $options): array; +} diff --git a/src/Domain/Services/Environment/Adapter/Dotenv.php b/src/Domain/Services/Environment/Adapter/Dotenv.php new file mode 100644 index 0000000..98fd11a --- /dev/null +++ b/src/Domain/Services/Environment/Adapter/Dotenv.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\Environment\Adapter; + +use Dotenv\Dotenv as ParentDotenv; +use Phalcon\Api\Domain\Services\Environment\EnvManager; +use Phalcon\Api\Domain\Services\Exceptions\InvalidConfigurationArguments; + +use function file_exists; + +/** + * Reads .env files and returns the array back + * + * @phpstan-import-type TSettings from EnvManager + * @phpstan-type TDotEnvOptions array{ + * filePath?: string + * } + */ +class Dotenv implements AdapterInterface +{ + /** + * @param TDotEnvOptions $options + * + * @return TSettings + * @throws InvalidConfigurationArguments + */ + public function load(array $options): array + { + $filePath = $options['filePath'] ?? null; + if (true === empty($filePath)) { + throw new InvalidConfigurationArguments( + 'The .env directory or file path was not specified.' + ); + } + + // If $filePath is a file, use its directory; if it's a directory, use as is + $envDir = is_dir($filePath) ? $filePath : dirname($filePath); + if (!is_dir($envDir)) { + throw new InvalidConfigurationArguments( + 'The .env directory does not exist at the specified path: ' . $envDir + ); + } + + $dotenv = ParentDotenv::createImmutable($envDir); + $dotenv->load(); + + /** @var TSettings $env */ + $env = $_ENV; + + return $env; + } +} diff --git a/src/Domain/Services/Environment/EnvFactory.php b/src/Domain/Services/Environment/EnvFactory.php new file mode 100644 index 0000000..ddf8e31 --- /dev/null +++ b/src/Domain/Services/Environment/EnvFactory.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\Environment; + +use Phalcon\Api\Domain\Services\Environment\Adapter\AdapterInterface; +use Phalcon\Api\Domain\Services\Environment\Adapter\Dotenv; +use Phalcon\Api\Domain\Services\Exceptions\InvalidConfigurationArguments; + +/** + * Factory for creating environment configuration adapters. + */ +class EnvFactory +{ + /** + * @var array + */ + protected array $mapper = []; + + /** + * @var array + */ + protected array $services = []; + + /** + * @param array $services + */ + public function __construct(array $services = []) + { + $this->init($services); + } + + /** + * Create a new instance of the object + * + * @param string $name + * @param mixed ...$parameters + * + * @return AdapterInterface + * @throws InvalidConfigurationArguments + */ + public function newInstance(string $name, mixed ...$parameters): AdapterInterface + { + if (true !== isset($this->services[$name])) { + $definition = $this->getService($name); + + /** @var AdapterInterface $instance */ + $instance = new $definition(...$parameters); + $this->services[$name] = $instance; + } + + return $this->services[$name]; + } + + /** + * @return array + */ + protected function getAdapters(): array + { + return [ + 'dotenv' => Dotenv::class, + ]; + } + + /** + * Return a service from the mapper - if it does not exist + * throws an exception + * + * @param string $name + * + * @return class-string + * @throws InvalidConfigurationArguments + */ + protected function getService(string $name): string + { + if (true !== isset($this->mapper[$name])) { + throw new InvalidConfigurationArguments("Service " . $name . " is not registered"); + } + + return $this->mapper[$name]; + } + + /** + * AdapterFactory constructor. + * + * @param array $services + */ + protected function init(array $services = []): void + { + $adapters = $this->getAdapters(); + $adapters = $adapters + $services; + /** + * @var string $name + * @var class-string $service + */ + foreach ($adapters as $name => $service) { + $this->mapper[$name] = $service; + unset($this->services[$name]); + } + } +} diff --git a/src/Domain/Services/Environment/EnvManager.php b/src/Domain/Services/Environment/EnvManager.php new file mode 100644 index 0000000..530e227 --- /dev/null +++ b/src/Domain/Services/Environment/EnvManager.php @@ -0,0 +1,235 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\Environment; + +use Phalcon\Api\Domain\Services\Exceptions\InvalidConfigurationArguments; + +/** + * Loads environment variables from various sources such as .env files or + * AWS Secrets Manager. + * + * The class reads the `APP_ENV_ADAPTER` environment variable to determine + * what adapter needs to be read. The adapters are: + * + * - `dotenv`: Reads from a `.env` file. Requires the `APP_ENV_FILE_PATH` + * environment variable to be set. If it is not set, it will try and read + * it from the root of the application. + * - `aws-secrets-manager`: Reads from AWS Secrets Manager. Requires the + * following: + * - `AWS_REGION`: The AWS region to connect to. + * - `AWS_ACCESS_KEY`: The AWS access key. + * - `AWS_SECRET`: The AWS secret key. + * + * @phpstan-type TSettings array + * @phpstan-type TOptions array + */ +class EnvManager +{ + private static bool $isLoaded = false; + + /** + * @var TSettings + */ + private static array $settings = []; + + /** + * Get the application environment. + * + * @return string + * @throws InvalidConfigurationArguments + */ + public static function appEnv(): string + { + self::load(); + + return self::getString('APP_ENV', 'development'); + } + + /** + * Get the application level + * + * @return int + * @throws InvalidConfigurationArguments + */ + public static function appLogLevel(): int + { + self::load(); + + return self::getInt('APP_LOG_LEVEL', 1); + } + + /** + * Get the application path. + * + * @param string $path + * + * @return string + */ + public static function appPath(string $path = ''): string + { + return dirname(__DIR__, 4) + . ($path ? DIRECTORY_SEPARATOR . $path : $path); + } + + /** + * Returns the application start time in nanoseconds. + * + * @return int + */ + public static function appTime(): int + { + return hrtime(true); + } + + /** + * Returns the application timezone. + * + * @return string + * @throws InvalidConfigurationArguments + */ + public static function appTimezone(): string + { + self::load(); + + return self::getString('APP_TIMEZONE', 'UTC'); + } + + /** + * Returns the application version. + * + * @return string + * @throws InvalidConfigurationArguments + */ + public static function appVersion(): string + { + self::load(); + + return self::getString('APP_VERSION', '1.0.0'); + } + + /** + * @param string $key + * @param bool|int|string|null $defaultValue + * + * @return bool|int|string|null + * @throws InvalidConfigurationArguments + */ + public static function get( + string $key, + bool | int | string | null $defaultValue = null + ): bool | int | string | null { + self::load(); + + return self::$settings[$key] ?? $defaultValue; + } + + /** + * Get cache options from environment variables. + * + * @return TOptions + * @throws InvalidConfigurationArguments + */ + public static function getCacheOptions(): array + { + self::load(); + + return [ + 'host' => self::getString('REDIS_HOST', 'localhost'), + 'index' => 1, + 'lifetime' => self::getInt('CACHE_LIFETIME', 86400), + 'prefix' => self::getString('CACHE_PREFIX', '-rest-'), + 'port' => self::getInt('REDIS_PORT', 6379), + 'uniqueId' => self::getString('CACHE_PREFIX', '-rest-'), + ]; + } + + /** + * @param string $key + * @param int $defaultValue + * + * @return int + * @throws InvalidConfigurationArguments + */ + public static function getInt( + string $key, + int $defaultValue = 0 + ): int { + return (int)(self::get($key, $defaultValue)); + } + + /** + * @param string $key + * @param string $defaultValue + * + * @return string + * @throws InvalidConfigurationArguments + */ + public static function getString( + string $key, + string $defaultValue = '' + ): string { + return (string)(self::get($key, $defaultValue)); + } + + /** + * Returns the options for the AWS Secrets Manager adapter. + * + * @return array + */ + private static function getOptions(): array + { + $envs = array_merge(getenv(), $_ENV); + + /** @var string $filePath */ + $filePath = $envs['APP_ENV_FILE_PATH'] ?? self::appPath(); + + return [ + 'adapter' => 'dotenv', + 'filePath' => $filePath, + ]; + } + + /** + * @return void + * @throws InvalidConfigurationArguments + */ + private static function load(): void + { + if (true !== self::$isLoaded) { + self::$isLoaded = true; + + $envFactory = new EnvFactory(); + $options = self::getOptions(); + /** @var TOptions $envs */ + $envs = array_merge(getenv(), $_ENV); + /** @var string $adapter */ + $adapter = $options['adapter']; + /** @var TSettings $options */ + $options = $envFactory->newInstance($adapter)->load($options); + /** @var TOptions $envs */ + $envs = array_merge($envs, $options); + + self::$settings = array_map( + function ($value) { + return match ($value) { + 'true' => true, + 'false' => false, + default => $value + }; + }, + $envs + ); + } + } +} diff --git a/src/Domain/Services/Exceptions/Exception.php b/src/Domain/Services/Exceptions/Exception.php new file mode 100644 index 0000000..0767d32 --- /dev/null +++ b/src/Domain/Services/Exceptions/Exception.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\Exceptions; + +/** + * Base exception + */ +class Exception extends \Exception +{ +} diff --git a/src/Domain/Services/Exceptions/InvalidConfigurationArguments.php b/src/Domain/Services/Exceptions/InvalidConfigurationArguments.php new file mode 100644 index 0000000..285e316 --- /dev/null +++ b/src/Domain/Services/Exceptions/InvalidConfigurationArguments.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\Exceptions; + +/** + * Invalid configuration arguments + */ +class InvalidConfigurationArguments extends Exception +{ +} diff --git a/tests/Unit/.gitkeep b/src/Responder/.gitkeep similarity index 100% rename from tests/Unit/.gitkeep rename to src/Responder/.gitkeep diff --git a/tests/Fixtures/Domain/AbstractDatabaseTestCase.php b/tests/Fixtures/Domain/AbstractDatabaseTestCase.php new file mode 100644 index 0000000..ac98850 --- /dev/null +++ b/tests/Fixtures/Domain/AbstractDatabaseTestCase.php @@ -0,0 +1,202 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Fixtures\Domain; + +use PDO; +use Phalcon\Api\Domain\Services\Environment\EnvManager; +use Phalcon\Api\Domain\Services\Exceptions\InvalidConfigurationArguments; +use Phalcon\DataMapper\Pdo\Connection; +use PHPUnit\Framework\Assert; + +use function sprintf; + +abstract class AbstractDatabaseTestCase extends AbstractUnitTestCase +{ + /** + * @var Connection|null + */ + private static ?Connection $connection = null; + + /** + * @var string + */ + private static string $password = ''; + + /** + * @var string + */ + private static string $username = ''; + + /** + * @param string $table + * @param array $criteria + * + * @return void + */ + public function assertInDatabase(string $table, array $criteria = []): void + { + $records = $this->getFromDatabase($table, $criteria); + + $this->assertNotEmpty($records); + } + + /** + * @param string $table + * @param array $criteria + * + * @return void + */ + public function assertNotInDatabase(string $table, array $criteria = []): void + { + $records = $this->getFromDatabase($table, $criteria); + + $this->assertSame([], $records); + } + + public function deleteById(string $table, string $field, int $id): int + { + $sql = "DELETE FROM $table WHERE $field = $id"; + + $connection = self::$connection; + if (!$result = $connection->exec($sql)) { + Assert::fail("Failed to delete $table with ID '$id'"); + } + + return $result; + } + + public function deleteUserById(int $userId): int + { + return $this->deleteById('co_users', 'usr_id', $userId); + } + + /** + * @return Connection|null + * @throws InvalidConfigurationArguments + */ + public static function getConnection(): Connection | null + { + if (null === self::$connection) { + self::$connection = new Connection( + self::getDatabaseDsn(), + self::getDatabaseUsername(), + self::getDatabasePassword() + ); + } + + return self::$connection; + } + /** + * @return string + * @throws InvalidConfigurationArguments + */ + public static function getDatabaseDsn(): string + { + $options = self::getDatabaseOptions(); + + self::$password = $options['password']; + self::$username = $options['username']; + + return sprintf( + "mysql:host=%s;dbname=%s;charset=%s;port=%s", + $options['host'], + $options['dbname'], + $options['charset'], + $options['port'] + ); + } + + /** + * @return string + */ + public static function getDatabaseNow(): string + { + return "NOW()"; + } + + /** + * @return array + * @throws InvalidConfigurationArguments + */ + public static function getDatabaseOptions(): array + { + return [ + 'host' => EnvManager::getString('DB_HOST', '127.0.0.1'), + 'username' => EnvManager::getString('DB_USER', 'root'), + 'password' => EnvManager::getString('DB_PASSWORD', 'secret'), + 'port' => EnvManager::getInt('DB_PORT', 5432), + 'dbname' => EnvManager::getString('DB_NAME', 'phalcon'), + 'schema' => EnvManager::getString('DB_CHARSET', 'utf8'), + ]; + } + + /** + * @return string + */ + public static function getDatabasePassword(): string + { + return self::$password; + } + + /** + * @return string + */ + public static function getDatabaseUsername(): string + { + return self::$username; + } + + /** + * @return void + */ + public static function setUpBeforeClass(): void + { + } + + /** + * @return void + */ + public static function tearDownAfterClass(): void + { + self::$connection = null; + } + + /** + * Return records from the database + * + * @param string $table + * @param array $criteria + * + * @return array + */ + protected function getFromDatabase(string $table, array $criteria = []): array + { + $sql = 'SELECT * FROM ' . $table . ' WHERE '; + $where = []; + foreach ($criteria as $key => $value) { + $val = $value; + if (is_string($value)) { + $val = '"' . $value . '"'; + } + + $where[] = $key . ' = ' . $val; + } + $sql .= implode(' AND ', $where); + + $connection = self::$connection; + $result = $connection->query($sql); + + return $result->fetchAll(PDO::FETCH_ASSOC); + } +} diff --git a/tests/Fixtures/Domain/AbstractUnitTestCase.php b/tests/Fixtures/Domain/AbstractUnitTestCase.php new file mode 100644 index 0000000..f1f6cf6 --- /dev/null +++ b/tests/Fixtures/Domain/AbstractUnitTestCase.php @@ -0,0 +1,155 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Fixtures\Domain; + +use FilesystemIterator; +use PHPUnit\Framework\TestCase; +use Random\RandomException; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; + +use function base64_encode; +use function file_exists; +use function file_get_contents; +use function gc_collect_cycles; +use function is_dir; +use function is_file; +use function random_bytes; +use function rmdir; +use function rtrim; +use function substr; +use function uniqid; +use function unlink; + +use const DIRECTORY_SEPARATOR; + +abstract class AbstractUnitTestCase extends TestCase +{ + /** + * @param string $fileName + * @param string $stream + * + * @return void + */ + public function assertFileContentsContains(string $fileName, string $stream): void + { + $contents = file_get_contents($fileName); + $this->assertStringContainsString($stream, $contents); + } + + /** + * @param string $fileName + * @param string $stream + * + * @return void + */ + public function assertFileContentsEqual(string $fileName, string $stream): void + { + $contents = file_get_contents($fileName); + $this->assertEquals($contents, $stream); + } + + /** + * Returns a directory string with the trailing directory separator + * + * @param string $directory + * + * @return string + */ + public function getDirSeparator(string $directory): string + { + return rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + } + + /** + * Returns a unique file name + * + * @param string $prefix A prefix for the file + * @param string $suffix A suffix for the file + * + * @return string + */ + public function getNewFileName( + string $prefix = '', + string $suffix = 'log' + ): string { + $prefix = ($prefix) ? $prefix . '_' : ''; + $suffix = ($suffix) ?: 'log'; + + return uniqid($prefix, true) . '.' . $suffix; + } + + /** + * Return a long series of strings to be used as a password + * + * @return string + * @throws RandomException + */ + public function getStrongPassword(): string + { + return substr(base64_encode(random_bytes(512)), 0, 128); + } + + /** + * Deletes a directory recursively + * + * @param string $directory + */ + public function safeDeleteDirectory(string $directory): void + { + if (is_dir($directory)) { + $dirIterator = new RecursiveDirectoryIterator( + $directory, + FilesystemIterator::SKIP_DOTS + ); + $iterator = new RecursiveIteratorIterator( + $dirIterator, + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $fileInfo) { + if ($fileInfo->isDir() === true) { + $this->safeDeleteDirectory($fileInfo->getRealPath()); + continue; + } + + if ( + empty($fileInfo->getRealPath()) === false && + file_exists($fileInfo->getRealPath()) + ) { + unlink($fileInfo->getRealPath()); + } + } + + if (is_dir($directory)) { + rmdir($directory); + } + } + } + + /** + * Deletes a file if it exists + * + * @param string $filename + * + * @return void + */ + public function safeDeleteFile(string $filename): void + { + if (file_exists($filename) && is_file($filename)) { + gc_collect_cycles(); + unlink($filename); + } + } +} diff --git a/tests/Fixtures/Domain/Services/Environment/.env b/tests/Fixtures/Domain/Services/Environment/.env new file mode 100644 index 0000000..b707f0d --- /dev/null +++ b/tests/Fixtures/Domain/Services/Environment/.env @@ -0,0 +1,4 @@ +SAMPLE_STRING=sample_value +SAMPLE_INT=1 +SAMPLE_TRUE=true +SAMPLE_FALSE=false diff --git a/tests/Unit/Domain/Common/Services/ContainerTest.php b/tests/Unit/Domain/Common/Services/ContainerTest.php new file mode 100644 index 0000000..07716d0 --- /dev/null +++ b/tests/Unit/Domain/Common/Services/ContainerTest.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Common\Services; + +use Phalcon\Api\Domain\Services\Container; +use Phalcon\Api\Tests\Fixtures\Domain\AbstractUnitTestCase; +use Phalcon\Cache\Cache; +use Phalcon\DataMapper\Pdo\Connection; +use Phalcon\Encryption\Security; +use Phalcon\Events\Manager as EventsManager; +use Phalcon\Filter\Filter; +use Phalcon\Http\Message\Request; +use Phalcon\Http\Message\Response; +use Phalcon\Logger\Logger; +use Phalcon\Mvc\Router; + +final class ContainerTest extends AbstractUnitTestCase +{ + /** + * @return void + */ + public function testServices(): void + { + $container = new Container(); + $services = $container->getServices(); + + $expected = 9; + $actual = count($services); + $this->assertSame($expected, $actual); + } + + /** + * @return void + */ + public function testRegisteredServices() + { + $container = new Container(); + + $actual = $container->has(Container::LOGGER); + $this->assertTrue($actual); + + $actual = $container->has(Container::CACHE); + $this->assertTrue($actual); + + $actual = $container->has(Container::CONNECTION); + $this->assertTrue($actual); + + $actual = $container->has(Container::EVENTS_MANAGER); + $this->assertTrue($actual); + + $actual = $container->has(Container::FILTER); + $this->assertTrue($actual); + + $actual = $container->has(Container::REQUEST); + $this->assertTrue($actual); + + $actual = $container->has(Container::RESPONSE); + $this->assertTrue($actual); + + $actual = $container->has(Container::ROUTER); + $this->assertTrue($actual); + + $actual = $container->has(Container::SECURITY); + $this->assertTrue($actual); + + $actual = $container->get(Container::LOGGER); + $this->assertTrue($actual instanceof Logger); + + $actual = $container->get(Container::CACHE); + $this->assertTrue($actual instanceof Cache); + + $actual = $container->get(Container::CONNECTION); + $this->assertTrue($actual instanceof Connection); + + $actual = $container->get(Container::EVENTS_MANAGER); + $this->assertTrue($actual instanceof EventsManager); + + $actual = $container->get(Container::FILTER); + $this->assertTrue($actual instanceof Filter); + + $actual = $container->get(Container::REQUEST); + $this->assertTrue($actual instanceof Request); + + $actual = $container->get(Container::RESPONSE); + $this->assertTrue($actual instanceof Response); + + $actual = $container->get(Container::ROUTER); + $this->assertTrue($actual instanceof Router); + + $actual = $container->get(Container::SECURITY); + $this->assertTrue($actual instanceof Security); + } +} diff --git a/tests/Unit/Domain/Common/Services/Environment/Adapter/DotenvTest.php b/tests/Unit/Domain/Common/Services/Environment/Adapter/DotenvTest.php new file mode 100644 index 0000000..0fe3375 --- /dev/null +++ b/tests/Unit/Domain/Common/Services/Environment/Adapter/DotenvTest.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Common\Services\Environment\Adapter; + +use Phalcon\Api\Domain\Services\Environment\Adapter\Dotenv; +use Phalcon\Api\Domain\Services\Environment\EnvManager; +use Phalcon\Api\Domain\Services\Exceptions\InvalidConfigurationArguments; +use Phalcon\Api\Tests\Fixtures\Domain\AbstractUnitTestCase; +use PHPUnit\Framework\Attributes\BackupGlobals; + +#[BackupGlobals(true)] +final class DotenvTest extends AbstractUnitTestCase +{ + private string $envFile; + + public function testLoadReturnsEnvArray(): void + { + $dotenv = new Dotenv(); + $options = ['filePath' => $this->envFile]; + $expected = [ + 'SAMPLE_STRING' => 'sample_value', + 'SAMPLE_INT' => '1', + 'SAMPLE_TRUE' => 'true', + 'SAMPLE_FALSE' => 'false', + ]; + + $actual = $dotenv->load($options); + + $this->assertArrayHasKey('SAMPLE_STRING', $actual); + $this->assertArrayHasKey('SAMPLE_INT', $actual); + $this->assertArrayHasKey('SAMPLE_TRUE', $actual); + $this->assertArrayHasKey('SAMPLE_FALSE', $actual); + + $this->assertSame($expected['SAMPLE_STRING'], $actual['SAMPLE_STRING']); + $this->assertSame($expected['SAMPLE_INT'], $actual['SAMPLE_INT']); + $this->assertSame($expected['SAMPLE_TRUE'], $actual['SAMPLE_TRUE']); + $this->assertSame($expected['SAMPLE_FALSE'], $actual['SAMPLE_FALSE']); + } + + public function testLoadThrowsExceptionForEmptyFilePath() + { + $this->expectException(InvalidConfigurationArguments::class); + $this->expectExceptionMessage( + 'The .env directory or file path was not specified.' + ); + + $dotenv = new Dotenv(); + $options = ['filePath' => '']; + $dotenv->load($options); + } + + public function testLoadThrowsExceptionForMissingFile() + { + $this->expectException(InvalidConfigurationArguments::class); + $this->expectExceptionMessage( + 'The .env directory does not exist at the specified path: /nonexistent/path' + ); + + $dotenv = new Dotenv(); + $options = ['filePath' => '/nonexistent/path/.env']; + $dotenv->load($options); + } + + protected function setUp(): void + { + $this->envFile = EnvManager::appPath() + . '/tests/Fixtures/Domain/Services/Environment/'; + } +} diff --git a/tests/Unit/Domain/Common/Services/Environment/EnvFactoryTest.php b/tests/Unit/Domain/Common/Services/Environment/EnvFactoryTest.php new file mode 100644 index 0000000..111326d --- /dev/null +++ b/tests/Unit/Domain/Common/Services/Environment/EnvFactoryTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Common\Services\Environment; + +use Phalcon\Api\Domain\Services\Environment\Adapter\Dotenv; +use Phalcon\Api\Domain\Services\Environment\EnvFactory; +use Phalcon\Api\Domain\Services\Exceptions\InvalidConfigurationArguments; +use Phalcon\Api\Tests\Fixtures\Domain\AbstractUnitTestCase; + +final class EnvFactoryTest extends AbstractUnitTestCase +{ + /** + * @return void + * @throws InvalidConfigurationArguments + */ + public function testLoad(): void + { + $factory = new EnvFactory(); + $dotEnv = $factory->newInstance('dotenv'); + + $class = Dotenv::class; + $this->assertInstanceOf($class, $dotEnv); + } + + /** + * @return void + * @throws InvalidConfigurationArguments + */ + public function testUnknownService(): void + { + $this->expectException(InvalidConfigurationArguments::class); + $this->expectExceptionMessage('Service unknown is not registered'); + + $factory = new EnvFactory(); + $factory->newInstance('unknown'); + } +} diff --git a/tests/Unit/Domain/Common/Services/Environment/EnvManagerTest.php b/tests/Unit/Domain/Common/Services/Environment/EnvManagerTest.php new file mode 100644 index 0000000..19583a5 --- /dev/null +++ b/tests/Unit/Domain/Common/Services/Environment/EnvManagerTest.php @@ -0,0 +1,263 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Common\Services\Environment; + +use Phalcon\Api\Domain\Services\Environment\EnvManager; +use Phalcon\Api\Domain\Services\Exceptions\InvalidConfigurationArguments; +use Phalcon\Api\Tests\Fixtures\Domain\AbstractUnitTestCase; +use PHPUnit\Framework\Attributes\BackupGlobals; +use ReflectionClass; +use ReflectionException; + +#[BackupGlobals(true)] +final class EnvManagerTest extends AbstractUnitTestCase +{ + /** + * @return void + * @throws InvalidConfigurationArguments + */ + public function testAppEnvReturnsDefault(): void + { + $expected = 'development'; + $actual = EnvManager::appEnv(); + $this->assertSame($expected, $actual); + } + + /** + * @return void + * @throws InvalidConfigurationArguments + */ + public function testAppEnvReturnsValue(): void + { + $_ENV = ['APP_ENV' => 'production']; + $expected = 'production'; + $actual = EnvManager::appEnv(); + $this->assertSame($expected, $actual); + } + + /** + * @return void + * @throws InvalidConfigurationArguments + */ + public function testAppLogLevelReturnsDefault(): void + { + $expected = 1; + $actual = EnvManager::appLogLevel(); + $this->assertSame($expected, $actual); + } + + /** + * @return void + * @throws InvalidConfigurationArguments + */ + public function testAppLogLevelReturnsValue(): void + { + $_ENV = ['APP_LOG_LEVEL' => 5]; + $expected = 5; + $actual = EnvManager::appLogLevel(); + $this->assertSame($expected, $actual); + } + + /** + * @return void + */ + public function testAppPathReturnsRoot(): void + { + $expected = dirname(__DIR__, 6); + $actual = EnvManager::appPath(); + $this->assertSame($expected, $actual); + } + + /** + * @return void + */ + public function testAppPathReturnsWithSubPath(): void + { + $expected = dirname(__DIR__, 6) . DIRECTORY_SEPARATOR . 'path'; + $actual = EnvManager::appPath('path'); + $this->assertSame($expected, $actual); + } + + /** + * @return void + */ + public function testAppTimeReturnsInt(): void + { + $actual = EnvManager::appTime(); + $this->assertIsInt($actual); + } + + /** + * @return void + * @throws InvalidConfigurationArguments + */ + public function testAppTimezoneReturnsDefault(): void + { + $expected = 'UTC'; + $actual = EnvManager::appTimezone(); + $this->assertSame($expected, $actual); + } + + /** + * @return void + * @throws InvalidConfigurationArguments + */ + public function testAppTimezoneReturnsValue(): void + { + $_ENV = ['APP_TIMEZONE' => 'America/New_York']; + $expected = 'America/New_York'; + $actual = EnvManager::appTimezone(); + $this->assertSame($expected, $actual); + } + + /** + * @return void + * @throws InvalidConfigurationArguments + */ + public function testAppVersionReturnsDefault(): void + { + $expected = '1.0.0'; + $actual = EnvManager::appVersion(); + $this->assertSame($expected, $actual); + } + + /** + * @return void + * @throws InvalidConfigurationArguments + */ + public function testAppVersionReturnsValue(): void + { + $_ENV = ['APP_VERSION' => '2.3.4']; + $expected = '2.3.4'; + $actual = EnvManager::appVersion(); + $this->assertSame($expected, $actual); + } + + /** + * @return void + * @throws InvalidConfigurationArguments + */ + public function testGetCacheOptionsReturnsDefaults(): void + { + $inCi = getenv('CIRCLECI'); + $host = 'true' === $inCi ? '127.0.0.1' : 'localhost'; + + $expected = [ + 'host' => $host, + 'index' => 1, + 'lifetime' => 86400, + 'prefix' => '-rest-', + 'port' => 6379, + 'uniqueId' => '-rest-', + ]; + + $actual = EnvManager::getCacheOptions(); + $this->assertSame($expected, $actual); + } + + /** + * @return void + * @throws InvalidConfigurationArguments + */ + public function testGetCacheOptionsReturnsValues(): void + { + $_ENV = [ + 'CACHE_PREFIX' => 'prefix', + 'REDIS_HOST' => 'redis', + 'CACHE_LIFETIME' => 123, + 'REDIS_PORT' => 456, + ]; + + $expected = [ + 'host' => 'redis', + 'index' => 1, + 'lifetime' => 123, + 'prefix' => 'prefix', + 'port' => 456, + 'uniqueId' => 'prefix', + ]; + $actual = EnvManager::getCacheOptions(); + $this->assertSame($expected, $actual); + } + + /** + * @return void + * @throws InvalidConfigurationArguments + */ + public function testGetFromDotEnvLoad(): void + { + $_ENV = [ + 'APP_ENV_ADAPTER' => 'dotenv', + 'APP_ENV_FILE_PATH' => EnvManager::appPath() + . '/tests/Fixtures/Domain/Services/Environment/', + ]; + + $values = [ + 'SAMPLE_STRING' => 'sample_value', + 'SAMPLE_INT' => '1', + 'SAMPLE_TRUE' => true, + 'SAMPLE_FALSE' => false, + ]; + $expected = $values['SAMPLE_STRING']; + $actual = EnvManager::get('SAMPLE_STRING'); + $this->assertSame($expected, $actual); + + $expected = $values['SAMPLE_INT']; + $actual = EnvManager::get('SAMPLE_INT'); + $this->assertSame($expected, $actual); + + $expected = $values['SAMPLE_TRUE']; + $actual = EnvManager::get('SAMPLE_TRUE'); + $this->assertSame($expected, $actual); + + $expected = $values['SAMPLE_FALSE']; + $actual = EnvManager::get('SAMPLE_FALSE'); + $this->assertSame($expected, $actual); + } + + /** + * @return void + * @throws InvalidConfigurationArguments + */ + public function testGetReturnsDefault(): void + { + $expected = 'default'; + $actual = EnvManager::get('NON_EXISTENT', 'default'); + $this->assertSame($expected, $actual); + } + + /** + * @return void + * @throws InvalidConfigurationArguments + */ + public function testGetReturnsValue(): void + { + $_ENV = ['SAMPLE_ENV' => 'sample_value']; + $expected = 'sample_value'; + $actual = EnvManager::get('SAMPLE_ENV'); + $this->assertSame($expected, $actual); + } + + /** + * @return void + * @throws ReflectionException + */ + protected function setUp(): void + { + // Reset static properties before each test + $ref = new ReflectionClass(EnvManager::class); + $ref->setStaticPropertyValue('isLoaded', false); + $ref->setStaticPropertyValue('settings', []); + } +}