diff --git a/.gitignore b/.gitignore index fe85b539..3b89caf4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ Thumbs.db # Test & Runtime artifacts # ========================= /tests/_root/shared/store/* +/tests/_root/cron-tests/ /coverage/ *.log diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 159c9b14..706c07c3 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,6 +1,6 @@ parameters: phpVersion: 70400 - level: 6 + level: 7 paths: - src diff --git a/src/App/Adapters/ConsoleAppAdapter.php b/src/App/Adapters/ConsoleAppAdapter.php index e2b86d87..4d2875f5 100644 --- a/src/App/Adapters/ConsoleAppAdapter.php +++ b/src/App/Adapters/ConsoleAppAdapter.php @@ -44,7 +44,7 @@ class ConsoleAppAdapter extends AppAdapter protected ConsoleOutput $output; - protected ?Application $application = null; + protected Application $application; public function __construct() { @@ -59,6 +59,11 @@ public function __construct() $this->loadEnvironment(); $this->loadAppConfig(); } + + $this->application = $this->createApplication( + config()->get('app.name', 'UNKNOWN'), + config()->get('app.version', 'UNKNOWN') + ); } /** @@ -72,11 +77,6 @@ public function __construct() public function start(): ?int { try { - $this->application = $this->createApplication( - config()->get('app.name', 'UNKNOWN'), - config()->get('app.version', 'UNKNOWN') - ); - $this->loadLanguage(); $this->registerCoreCommands(); diff --git a/src/App/Adapters/WebAppAdapter.php b/src/App/Adapters/WebAppAdapter.php index 39681c5d..9dc0cf61 100644 --- a/src/App/Adapters/WebAppAdapter.php +++ b/src/App/Adapters/WebAppAdapter.php @@ -146,7 +146,7 @@ public function start(): ?int $viewCache = $this->setupViewCache(); - if ($viewCache->serveCachedView(route_uri(), $this->response)) { + if ($viewCache->serveCachedView(route_uri() ?? '', $this->response)) { stop(); } diff --git a/src/App/App.php b/src/App/App.php index 467d101f..406ba8f3 100644 --- a/src/App/App.php +++ b/src/App/App.php @@ -19,6 +19,7 @@ use Quantum\App\Exceptions\BaseException; use Quantum\App\Exceptions\AppException; use Quantum\App\Contracts\AppInterface; +use RuntimeException; /** * Class App @@ -42,6 +43,10 @@ public static function setBaseDir(string $baseDir): void public static function getBaseDir(): string { + if (self::$baseDir === null || self::$baseDir === '') { + throw new RuntimeException('Base directory is not initialized.'); + } + return self::$baseDir; } diff --git a/src/App/Exceptions/StopExecutionException.php b/src/App/Exceptions/StopExecutionException.php index 8512dcc5..f8c5e06d 100644 --- a/src/App/Exceptions/StopExecutionException.php +++ b/src/App/Exceptions/StopExecutionException.php @@ -28,7 +28,7 @@ public static function executionTerminated(?int $code): self { return new self( ExceptionMessages::EXECUTION_TERMINATED, - $code + $code ?? 0 ); } } diff --git a/src/App/Helpers/misc.php b/src/App/Helpers/misc.php index f51371ee..f4a333bc 100644 --- a/src/App/Helpers/misc.php +++ b/src/App/Helpers/misc.php @@ -24,9 +24,9 @@ function _message(string $subject, $params): string if (is_array($params)) { return preg_replace_callback('/{%\d+}/', function () use (&$params) { return array_shift($params); - }, $subject); + }, $subject) ?? $subject; } else { - return preg_replace('/{%\d+}/', $params, $subject); + return preg_replace('/{%\d+}/', $params, $subject) ?? $subject; } } @@ -62,8 +62,8 @@ function random_number(int $length = 10): int function slugify(string $text): string { $text = trim($text, ' '); - $text = preg_replace('/[^\p{L}\p{N}]/u', ' ', $text); - $text = preg_replace('/\s+/', '-', $text); + $text = preg_replace('/[^\p{L}\p{N}]/u', ' ', $text) ?? $text; + $text = preg_replace('/\s+/', '-', $text) ?? $text; $text = trim($text, '-'); $text = mb_strtolower($text); diff --git a/src/App/Traits/AppTrait.php b/src/App/Traits/AppTrait.php index 262ce94f..620cb44c 100644 --- a/src/App/Traits/AppTrait.php +++ b/src/App/Traits/AppTrait.php @@ -76,7 +76,9 @@ protected function loadComponentHelperFunctions(): void $srcDir = dirname(__DIR__, 2); - foreach (glob($srcDir . DS . '*', GLOB_ONLYDIR) as $componentDir) { + $componentDirs = glob($srcDir . DS . '*', GLOB_ONLYDIR); + + foreach (is_array($componentDirs) ? $componentDirs : [] as $componentDir) { $helperPath = $componentDir . DS . 'Helpers'; if (is_dir($helperPath)) { $loader->loadDir($helperPath); diff --git a/src/App/Traits/ConsoleAppTrait.php b/src/App/Traits/ConsoleAppTrait.php index 04f60052..85c2be9b 100644 --- a/src/App/Traits/ConsoleAppTrait.php +++ b/src/App/Traits/ConsoleAppTrait.php @@ -17,6 +17,7 @@ namespace Quantum\App\Traits; use Quantum\Environment\Exceptions\EnvException; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Application; use Quantum\App\Exceptions\BaseException; use Quantum\Di\Exceptions\DiException; @@ -80,7 +81,10 @@ private function registerCommands(string $directory, string $namespace): void $commands = CommandDiscovery::discover($directory, $namespace); foreach ($commands as $command) { - $this->application->add(new $command['class']()); + $instance = new $command['class'](); + if ($instance instanceof Command) { + $this->application->add($instance); + } } } @@ -91,7 +95,7 @@ private function validateCommand(): void { $commandName = $this->input->getFirstArgument(); - if (!$this->application->has($commandName)) { + if ($commandName === null || !$this->application->has($commandName)) { throw new Exception("Command `$commandName` is not defined"); } } diff --git a/src/Archive/Adapters/PharAdapter.php b/src/Archive/Adapters/PharAdapter.php index 163c7e3e..c9c9a882 100644 --- a/src/Archive/Adapters/PharAdapter.php +++ b/src/Archive/Adapters/PharAdapter.php @@ -107,7 +107,9 @@ public function addFile(string $filePath, ?string $entryName = null): bool } try { - $this->archive->addFile($filePath, $entryName); + $entryName !== null + ? $this->archive->addFile($filePath, $entryName) + : $this->archive->addFile($filePath); return true; } catch (Exception $e) { return false; @@ -213,6 +215,8 @@ public function deleteMultipleFiles(array $fileNames): bool /** * @throws ArchiveException + * @phpstan-assert Phar $this->archive + * @phpstan-assert string $this->archiveName */ private function ensureArchiveOpen(): void { diff --git a/src/Archive/Adapters/ZipAdapter.php b/src/Archive/Adapters/ZipAdapter.php index c7b4b19a..3129adbf 100644 --- a/src/Archive/Adapters/ZipAdapter.php +++ b/src/Archive/Adapters/ZipAdapter.php @@ -94,7 +94,9 @@ public function addFile(string $filePath, ?string $entryName = null): bool throw ArchiveException::fileNotFound($filePath); } - $result = $this->archive->addFile($filePath, $entryName); + $result = $entryName !== null + ? $this->archive->addFile($filePath, $entryName) + : $this->archive->addFile($filePath); $this->requiresReopen = true; return $result; @@ -195,6 +197,7 @@ public function deleteMultipleFiles(array $fileNames): bool /** * @throws ArchiveException + * @phpstan-assert ZipArchive $this->archive */ private function ensureArchiveOpen(): void { diff --git a/src/Asset/Asset.php b/src/Asset/Asset.php index 6ed96e9f..64ab140a 100644 --- a/src/Asset/Asset.php +++ b/src/Asset/Asset.php @@ -43,7 +43,7 @@ class Asset */ private ?array $attributes; - private ?int $position; + private int $position; /** * Asset templates @@ -58,7 +58,7 @@ class Asset * Asset constructor * @param array|null $attributes */ - public function __construct(int $type, string $path, ?string $name = null, ?int $position = -1, ?array $attributes = []) + public function __construct(int $type, string $path, ?string $name = null, int $position = -1, ?array $attributes = []) { $this->type = $type; $this->path = $path; @@ -76,7 +76,7 @@ public function getType(): int } /** - * Gets asset path + * Gets an asset path */ public function getPath(): string { @@ -94,7 +94,7 @@ public function getName(): ?string /** * Gets asset position */ - public function getPosition(): ?int + public function getPosition(): int { return $this->position; } @@ -127,7 +127,7 @@ public function tag(): string { return _message( $this->templates[$this->type], - [$this->url(), implode(' ', $this->attributes)] + [$this->url(), implode(' ', $this->attributes ?? [])] ) . PHP_EOL; } } diff --git a/src/Asset/AssetManager.php b/src/Asset/AssetManager.php index 03f31811..1c81558e 100644 --- a/src/Asset/AssetManager.php +++ b/src/Asset/AssetManager.php @@ -169,7 +169,7 @@ private function setPriorityAssets(): void $position = $asset->getPosition(); $type = $asset->getType(); - if ($position != -1) { + if ($position !== -1) { if (isset($this->published[$type][$position])) { throw AssetException::positionInUse($position, $asset->getPath()); } @@ -185,7 +185,7 @@ private function setPriorityAssets(): void private function setRegularAssets(): void { foreach ($this->store as $asset) { - if ($asset->getPosition() == -1) { + if ($asset->getPosition() === -1) { $this->setPosition($asset, 0); } } diff --git a/src/Auth/Adapters/JwtAuthAdapter.php b/src/Auth/Adapters/JwtAuthAdapter.php index 853b9dc5..daed3a4b 100644 --- a/src/Auth/Adapters/JwtAuthAdapter.php +++ b/src/Auth/Adapters/JwtAuthAdapter.php @@ -38,10 +38,12 @@ class JwtAuthAdapter implements AuthenticatableInterface { use AuthTrait; + protected JwtToken $jwt; + /** * @throws AuthException */ - public function __construct(AuthServiceInterface $authService, Mailer $mailer, Hasher $hasher, ?JwtToken $jwt = null) + public function __construct(AuthServiceInterface $authService, Mailer $mailer, Hasher $hasher, JwtToken $jwt) { $this->authService = $authService; $this->mailer = $mailer; @@ -138,11 +140,12 @@ public function verifyOtp(int $otp, string $otpToken): array /** * Get Updated Tokens - * @throws JwtException * @return array + * @throws JwtException */ protected function getUpdatedTokens(User $user): array { + return [ $this->keyFields[AuthKeys::REFRESH_TOKEN] => $this->generateToken(), $this->keyFields[AuthKeys::ACCESS_TOKEN] => base64_encode($this->jwt->setData($this->getVisibleFields($user))->compose()), @@ -151,8 +154,8 @@ protected function getUpdatedTokens(User $user): array /** * Set Updated Tokens - * @throws JwtException * @return array + * @throws JwtException */ protected function setUpdatedTokens(User $user): array { diff --git a/src/Auth/Factories/AuthFactory.php b/src/Auth/Factories/AuthFactory.php index c2e486cc..985b4f2a 100644 --- a/src/Auth/Factories/AuthFactory.php +++ b/src/Auth/Factories/AuthFactory.php @@ -16,14 +16,15 @@ namespace Quantum\Auth\Factories; +use Quantum\Auth\Contracts\AuthenticatableInterface; use Quantum\Auth\Contracts\AuthServiceInterface; use Quantum\Service\Exceptions\ServiceException; use Quantum\Config\Exceptions\ConfigException; use Quantum\Service\Factories\ServiceFactory; use Quantum\Auth\Adapters\SessionAuthAdapter; use Quantum\Auth\Exceptions\AuthException; -use Quantum\Auth\Adapters\JwtAuthAdapter; use Quantum\App\Exceptions\BaseException; +use Quantum\Auth\Adapters\JwtAuthAdapter; use Quantum\Di\Exceptions\DiException; use Quantum\Auth\Enums\AuthType; use Quantum\Service\QtService; @@ -87,12 +88,17 @@ public static function get(?string $adapter = null): Auth */ private static function createInstance(string $adapterClass, string $adapter): Auth { - return new Auth(new $adapterClass( - self::createAuthService($adapter), - mailer(), - new Hasher(), - self::createJwtInstance($adapter) - )); + $authService = self::createAuthService($adapter); + + $adapterInstance = $adapter === AuthType::JWT + ? new $adapterClass($authService, mailer(), new Hasher(), self::createJwtInstance()) + : new $adapterClass($authService, mailer(), new Hasher()); + + if (!$adapterInstance instanceof AuthenticatableInterface) { + throw AuthException::adapterNotSupported($adapter); + } + + return new Auth($adapterInstance); } /** @@ -127,8 +133,10 @@ private static function createAuthService(string $adapter): AuthServiceInterface return $authService; } - private static function createJwtInstance(string $adapter): ?JwtToken + private static function createJwtInstance(): JwtToken { - return $adapter === AuthType::JWT ? (new JwtToken())->setLeeway(1)->setClaims((array) config()->get('auth.claims')) : null; + return (new JwtToken()) + ->setLeeway(1) + ->setClaims((array) config()->get('auth.' . AuthType::JWT . '.claims')); } } diff --git a/src/Auth/Traits/AuthTrait.php b/src/Auth/Traits/AuthTrait.php index ccec97da..5a4c7505 100644 --- a/src/Auth/Traits/AuthTrait.php +++ b/src/Auth/Traits/AuthTrait.php @@ -25,7 +25,6 @@ use Quantum\Auth\Enums\AuthKeys; use Quantum\Mailer\Mailer; use Quantum\Hasher\Hasher; -use Quantum\Jwt\JwtToken; use ReflectionException; use Quantum\Auth\User; use ReflectionClass; @@ -39,40 +38,23 @@ */ trait AuthTrait { - /** - * @var Mailer - */ - protected $mailer; - - /** - * @var Hasher - */ - protected $hasher; + protected Mailer $mailer; - /** - * @var JwtToken - */ - protected $jwt; + protected Hasher $hasher; - /** - * @var AuthServiceInterface - */ - protected $authService; + protected AuthServiceInterface $authService; - /** - * @var int - */ - protected $otpLength = 6; + protected int $otpLength = 6; /** * @var array */ - protected $keyFields = []; + protected array $keyFields = []; /** * @var array */ - protected $visibleFields = []; + protected array $visibleFields = []; /** * @inheritDoc @@ -214,7 +196,7 @@ protected function getUser(string $username, string $password): User throw AuthException::incorrectCredentials(); } - if (!$this->hasher->check($password, $user->getFieldValue($this->keyFields[AuthKeys::PASSWORD]))) { + if (!$this->hasher->check($password, $user->getFieldValue($this->keyFields[AuthKeys::PASSWORD]) ?? '')) { throw AuthException::incorrectCredentials(); } @@ -275,7 +257,9 @@ protected function verifyAndUpdateOtp(int $otp, string $otpToken): User throw AuthException::incorrectVerificationCode(); } - if (new DateTime() >= new DateTime($user->getFieldValue($this->keyFields[AuthKeys::OTP_EXPIRY]))) { + $otpExpiry = $user->getFieldValue($this->keyFields[AuthKeys::OTP_EXPIRY]); + + if (!$otpExpiry || new DateTime() >= new DateTime($otpExpiry)) { throw AuthException::verificationCodeExpired(); } @@ -324,7 +308,7 @@ protected function isActivated(User $user): bool */ protected function generateToken(?string $val = null): string { - return base64_encode($this->hasher->hash($val ?: config()->get('app.key'))); + return base64_encode($this->hasher->hash($val ?: config()->get('app.key')) ?? ''); } /** @@ -339,7 +323,7 @@ protected function sendMail(User $user, array $body): void $appName = config()->get('app.name') ?: ''; $this->mailer->setFrom($appEmail, $appName) - ->setAddress($user->getFieldValue($this->keyFields[AuthKeys::USERNAME]), $fullName) + ->setAddress($user->getFieldValue($this->keyFields[AuthKeys::USERNAME]) ?? '', $fullName) ->setBody($body) ->send(); } diff --git a/src/Cache/Adapters/DatabaseAdapter.php b/src/Cache/Adapters/DatabaseAdapter.php index 2fc24830..555fb002 100644 --- a/src/Cache/Adapters/DatabaseAdapter.php +++ b/src/Cache/Adapters/DatabaseAdapter.php @@ -17,7 +17,9 @@ namespace Quantum\Cache\Adapters; use Quantum\Cache\Enums\ExceptionMessages; +use Quantum\App\Exceptions\BaseException; use Quantum\Model\Factories\ModelFactory; +use Quantum\Cache\Traits\CacheTrait; use Psr\SimpleCache\CacheInterface; use InvalidArgumentException; use Quantum\Model\DbModel; @@ -29,15 +31,7 @@ */ class DatabaseAdapter implements CacheInterface { - /** - * @var int - */ - private $ttl; - - /** - * @var string - */ - private $prefix; + use CacheTrait; private DbModel $cacheModel; @@ -53,12 +47,17 @@ public function __construct(array $params) /** * @inheritDoc + * @throws BaseException */ public function get($key, $default = null) { if ($this->has($key)) { $cacheItem = $this->cacheModel->findOneBy('key', $this->keyHash($key)); + if ($cacheItem === null) { + return $default; + } + try { return unserialize($cacheItem->prop('value')); } catch (Exception $e) { @@ -72,14 +71,17 @@ public function get($key, $default = null) /** * @inheritDoc - * @throws InvalidArgumentException * @param iterable $keys * @return iterable + * @throws InvalidArgumentException|BaseException */ public function getMultiple($keys, $default = null) { if (!is_array($keys)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$keys'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$keys'), + E_WARNING + ); } $result = []; @@ -98,11 +100,11 @@ public function has($key): bool { $cacheItem = $this->cacheModel->findOneBy('key', $this->keyHash($key)); - if (empty($cacheItem->asArray())) { + if ($cacheItem === null || empty($cacheItem->asArray())) { return false; } - if (time() - $cacheItem->prop('ttl') > $this->ttl) { + if (time() >= $cacheItem->prop('ttl')) { $this->delete($key); return false; } @@ -117,13 +119,13 @@ public function set($key, $value, $ttl = null): bool { $cacheItem = $this->cacheModel->findOneBy('key', $this->keyHash($key)); - if (empty($cacheItem->asArray())) { + if ($cacheItem === null || empty($cacheItem->asArray())) { $cacheItem = $this->cacheModel->create(); } $cacheItem->prop('key', $this->keyHash($key)); $cacheItem->prop('value', serialize($value)); - $cacheItem->prop('ttl', time()); + $cacheItem->prop('ttl', time() + $this->normalizeTtl($ttl)); return $cacheItem->save(); } @@ -136,7 +138,10 @@ public function set($key, $value, $ttl = null): bool public function setMultiple($values, $ttl = null): bool { if (!is_array($values)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), + E_WARNING + ); } $results = []; @@ -155,7 +160,7 @@ public function delete($key): bool { $cacheItem = $this->cacheModel->findOneBy('key', $this->keyHash($key)); - if (!empty($cacheItem->asArray())) { + if ($cacheItem !== null && !empty($cacheItem->asArray())) { return $cacheItem->delete(); } @@ -170,7 +175,10 @@ public function delete($key): bool public function deleteMultiple($keys): bool { if (!is_array($keys)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$keys'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$keys'), + E_WARNING + ); } $results = []; @@ -190,11 +198,4 @@ public function clear(): bool return $this->cacheModel->deleteMany(); } - /** - * Gets the hashed key - */ - private function keyHash(string $key): string - { - return sha1($this->prefix . $key); - } } diff --git a/src/Cache/Adapters/FileAdapter.php b/src/Cache/Adapters/FileAdapter.php index 3a43d6d1..37525de3 100644 --- a/src/Cache/Adapters/FileAdapter.php +++ b/src/Cache/Adapters/FileAdapter.php @@ -22,6 +22,7 @@ use Quantum\App\Exceptions\BaseException; use Quantum\Di\Exceptions\DiException; use Psr\SimpleCache\CacheInterface; +use Quantum\Cache\Traits\CacheTrait; use Quantum\Storage\FileSystem; use InvalidArgumentException; use ReflectionException; @@ -32,17 +33,9 @@ */ class FileAdapter implements CacheInterface { - private FileSystem $fs; - - /** - * @var int - */ - private $ttl; + use CacheTrait; - /** - * @var string - */ - private $prefix; + private FileSystem $fs; /** * @var string @@ -119,7 +112,7 @@ public function has($key): bool return false; } - if (time() - $this->fs->lastModified($path) > $this->ttl) { + if (time() >= $this->fs->lastModified($path)) { $this->delete($key); return false; } @@ -130,9 +123,17 @@ public function has($key): bool /** * @inheritDoc */ - public function set($key, $value, $ttl = null) + public function set($key, $value, $ttl = null): bool { - return $this->fs->put($this->getPath($key), serialize($value)); + $path = $this->getPath($key); + $result = $this->fs->put($path, serialize($value)) !== false; + + if ($result && !touch($path, time() + $this->normalizeTtl($ttl))) { + $this->fs->remove($path); + return false; + } + + return $result; } /** @@ -158,7 +159,7 @@ public function setMultiple($values, $ttl = null): bool /** * @inheritDoc */ - public function delete($key) + public function delete($key): bool { $path = $this->getPath($key); @@ -216,6 +217,6 @@ public function clear(): bool */ private function getPath(string $key): string { - return $this->cacheDir . DS . sha1($this->prefix . $key); + return $this->cacheDir . DS . $this->keyHash($key); } } diff --git a/src/Cache/Adapters/MemcachedAdapter.php b/src/Cache/Adapters/MemcachedAdapter.php index f682cded..eb8a5448 100644 --- a/src/Cache/Adapters/MemcachedAdapter.php +++ b/src/Cache/Adapters/MemcachedAdapter.php @@ -19,6 +19,7 @@ use Quantum\Cache\Exceptions\CacheException; use Quantum\Cache\Enums\ExceptionMessages; use Quantum\App\Exceptions\BaseException; +use Quantum\Cache\Traits\CacheTrait; use Psr\SimpleCache\CacheInterface; use InvalidArgumentException; use Memcached; @@ -30,15 +31,7 @@ */ class MemcachedAdapter implements CacheInterface { - /** - * @var int - */ - private $ttl; - - /** - * @var string - */ - private $prefix; + use CacheTrait; private Memcached $memcached; @@ -87,7 +80,10 @@ public function get($key, $default = null) public function getMultiple($keys, $default = null) { if (!is_array($keys)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$keys'), + E_WARNING + ); } $result = []; @@ -113,7 +109,7 @@ public function has($key): bool */ public function set($key, $value, $ttl = null): bool { - return $this->memcached->set($this->keyHash($key), serialize($value), $this->ttl); + return $this->memcached->set($this->keyHash($key), serialize($value), $this->normalizeTtl($ttl)); } /** @@ -124,7 +120,10 @@ public function set($key, $value, $ttl = null): bool public function setMultiple($values, $ttl = null): bool { if (!is_array($values)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), + E_WARNING + ); } $results = []; @@ -152,7 +151,10 @@ public function delete($key): bool public function deleteMultiple($keys): bool { if (!is_array($keys)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$keys'), + E_WARNING + ); } $results = []; @@ -172,11 +174,4 @@ public function clear(): bool return $this->memcached->flush(); } - /** - * Gets the hashed key - */ - private function keyHash(string $key): string - { - return sha1($this->prefix . $key); - } } diff --git a/src/Cache/Adapters/RedisAdapter.php b/src/Cache/Adapters/RedisAdapter.php index c384a120..0b63ab72 100644 --- a/src/Cache/Adapters/RedisAdapter.php +++ b/src/Cache/Adapters/RedisAdapter.php @@ -17,6 +17,7 @@ namespace Quantum\Cache\Adapters; use Quantum\Cache\Enums\ExceptionMessages; +use Quantum\Cache\Traits\CacheTrait; use Psr\SimpleCache\CacheInterface; use InvalidArgumentException; use RedisException; @@ -29,15 +30,7 @@ */ class RedisAdapter implements CacheInterface { - /** - * @var int - */ - private $ttl; - - /** - * @var string - */ - private $prefix; + use CacheTrait; private Redis $redis; @@ -82,7 +75,10 @@ public function get($key, $default = null) public function getMultiple($keys, $default = null) { if (!is_array($keys)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$keys'), + E_WARNING + ); } $result = []; @@ -107,9 +103,13 @@ public function has($key): bool * @inheritDoc * @throws RedisException */ - public function set($key, $value, $ttl = null) + public function set($key, $value, $ttl = null): bool { - return $this->redis->set($this->keyHash($key), serialize($value), $this->ttl); + return (bool) $this->redis->set( + $this->keyHash($key), + serialize($value), + $this->normalizeTtl($ttl) + ); } /** @@ -121,7 +121,10 @@ public function set($key, $value, $ttl = null) public function setMultiple($values, $ttl = null): bool { if (!is_array($values)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), + E_WARNING + ); } $results = []; @@ -149,7 +152,10 @@ public function delete($key): bool public function deleteMultiple($keys): bool { if (!is_array($keys)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$keys'), + E_WARNING + ); } $results = []; @@ -165,16 +171,8 @@ public function deleteMultiple($keys): bool * @inheritDoc * @throws RedisException */ - public function clear() - { - return $this->redis->flushdb(); - } - - /** - * Gets the hashed key - */ - private function keyHash(string $key): string + public function clear(): bool { - return sha1($this->prefix . $key); + return (bool) $this->redis->flushdb(); } } diff --git a/src/Cache/Factories/CacheFactory.php b/src/Cache/Factories/CacheFactory.php index d67b5738..7f2cc130 100644 --- a/src/Cache/Factories/CacheFactory.php +++ b/src/Cache/Factories/CacheFactory.php @@ -24,6 +24,7 @@ use Quantum\Cache\Adapters\RedisAdapter; use Quantum\Cache\Adapters\FileAdapter; use Quantum\Di\Exceptions\DiException; +use Psr\SimpleCache\CacheInterface; use Quantum\Cache\Enums\CacheType; use Quantum\Loader\Setup; use ReflectionException; @@ -73,9 +74,18 @@ public static function get(?string $adapter = null): Cache return self::$instances[$adapter]; } + /** + * @throws CacheException + */ private static function createInstance(string $adapterClass, string $adapter): Cache { - return new Cache(new $adapterClass(config()->get('cache.' . $adapter))); + $cacheAdapter = new $adapterClass(config()->get('cache.' . $adapter)); + + if (!$cacheAdapter instanceof CacheInterface) { + throw CacheException::adapterNotSupported($adapter); + } + + return new Cache($cacheAdapter); } /** diff --git a/src/Cache/Traits/CacheTrait.php b/src/Cache/Traits/CacheTrait.php new file mode 100644 index 00000000..de64e695 --- /dev/null +++ b/src/Cache/Traits/CacheTrait.php @@ -0,0 +1,66 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Cache\Traits; + +use DateTimeImmutable; +use DateInterval; + +/** + * Trait CacheTrait + * @package Quantum\Cache\Traits + */ +trait CacheTrait +{ + /** + * @var int + */ + protected $ttl; + + /** + * @var string + */ + protected $prefix; + + /** + * Gets the hashed key + */ + protected function keyHash(string $key): string + { + return sha1($this->prefix . $key); + } + + /** + * Normalizes the TTL + * @param int|DateInterval|null $ttl + * @return int + */ + protected function normalizeTtl($ttl): int + { + if ($ttl instanceof DateInterval) { + $now = new DateTimeImmutable(); + $future = $now->add($ttl); + + return max(0, $future->getTimestamp() - $now->getTimestamp()); + } + + if ($ttl === null) { + return $this->ttl; + } + + return (int) $ttl; + } +} diff --git a/src/Captcha/Factories/CaptchaFactory.php b/src/Captcha/Factories/CaptchaFactory.php index ad5b8da6..751e4613 100644 --- a/src/Captcha/Factories/CaptchaFactory.php +++ b/src/Captcha/Factories/CaptchaFactory.php @@ -17,6 +17,7 @@ namespace Quantum\Captcha\Factories; use Quantum\Captcha\Exceptions\CaptchaException; +use Quantum\Captcha\Contracts\CaptchaInterface; use Quantum\Captcha\Adapters\RecaptchaAdapter; use Quantum\Config\Exceptions\ConfigException; use Quantum\Captcha\Adapters\HcaptchaAdapter; @@ -70,9 +71,18 @@ public static function get(?string $adapter = null): Captcha return self::$instances[$adapter]; } + /** + * @throws CaptchaException + */ private static function createInstance(string $adapterClass, string $adapter): Captcha { - return new Captcha(new $adapterClass(config()->get('captcha.' . $adapter), new HttpClient())); + $adapterInstance = new $adapterClass(config()->get('captcha.' . $adapter), new HttpClient()); + + if (!$adapterInstance instanceof CaptchaInterface) { + throw CaptchaException::adapterNotSupported($adapter); + } + + return new Captcha($adapterInstance); } /** diff --git a/src/Captcha/Traits/CaptchaTrait.php b/src/Captcha/Traits/CaptchaTrait.php index 4e2ac7de..4ebd4e15 100644 --- a/src/Captcha/Traits/CaptchaTrait.php +++ b/src/Captcha/Traits/CaptchaTrait.php @@ -50,7 +50,7 @@ trait CaptchaTrait protected array $errorCodes = []; /** - * @var array + * @var array */ protected array $elementAttributes = []; diff --git a/src/Console/Commands/DebugBarCommand.php b/src/Console/Commands/DebugBarCommand.php index 6e0b0b40..ba501c48 100644 --- a/src/Console/Commands/DebugBarCommand.php +++ b/src/Console/Commands/DebugBarCommand.php @@ -18,9 +18,7 @@ use Quantum\Storage\Exceptions\FileSystemException; use Quantum\Storage\Factories\FileSystemFactory; -use Quantum\Config\Exceptions\ConfigException; use Quantum\App\Exceptions\BaseException; -use Quantum\Di\Exceptions\DiException; use Quantum\Storage\FileSystem; use Quantum\Console\QtCommand; use ReflectionException; @@ -34,7 +32,7 @@ class DebugBarCommand extends QtCommand /** * File System */ - protected ?FileSystem $fs = null; + protected FileSystem $fs; /** * Command name @@ -61,17 +59,21 @@ class DebugBarCommand extends QtCommand */ private string $vendorDebugBarFolderPath = 'vendor/php-debugbar/php-debugbar/src/DebugBar/Resources'; + /** + * @throws BaseException|ReflectionException + */ + public function __construct() + { + parent::__construct(); + $this->fs = FileSystemFactory::get(); + } + /** * Executes the command and publishes the debug bar assets - * @throws BaseException - * @throws FileSystemException - * @throws ConfigException - * @throws DiException - * @throws ReflectionException + * @throws FileSystemException|BaseException */ public function exec(): void { - $this->fs = FileSystemFactory::get(); if ($this->fs->exists(assets_dir() . DS . 'DebugBar' . DS . 'Resources' . DS . 'debugbar.css')) { $this->error('The debug ber already installed'); diff --git a/src/Console/Commands/InstallToolkitCommand.php b/src/Console/Commands/InstallToolkitCommand.php index 790ed7fe..82ab3878 100644 --- a/src/Console/Commands/InstallToolkitCommand.php +++ b/src/Console/Commands/InstallToolkitCommand.php @@ -22,6 +22,7 @@ use Symfony\Component\Console\Input\ArrayInput; use Quantum\Environment\Environment; use Quantum\Console\QtCommand; +use RuntimeException; /** * Class InstallToolkitCommand @@ -88,13 +89,18 @@ public function exec(): void /** * Runs an external command - * @throws ExceptionInterface * @param array $arguments - * @return void + * @throws ExceptionInterface */ protected function runExternalCommand(string $commandName, array $arguments): void { - $command = $this->getApplication()->find($commandName); + $application = $this->getApplication(); + + if ($application === null) { + throw new RuntimeException('Application is not set.'); + } + + $command = $application->find($commandName); $command->run(new ArrayInput($arguments), new NullOutput()); } } diff --git a/src/Console/Commands/KeyGenerateCommand.php b/src/Console/Commands/KeyGenerateCommand.php index 10fe0b72..2a99090d 100644 --- a/src/Console/Commands/KeyGenerateCommand.php +++ b/src/Console/Commands/KeyGenerateCommand.php @@ -77,6 +77,6 @@ public function exec(): void */ private function generateRandomKey(): string { - return bin2hex(random_bytes((int) $this->getOption('length'))); + return bin2hex(random_bytes(max(1, (int) $this->getOption('length')))); } } diff --git a/src/Console/Commands/OpenApiCommand.php b/src/Console/Commands/OpenApiCommand.php index a9fbbff8..5df57383 100644 --- a/src/Console/Commands/OpenApiCommand.php +++ b/src/Console/Commands/OpenApiCommand.php @@ -130,7 +130,11 @@ public function exec(): void } if (!route_group_exists('openapi', $module)) { - $this->fs->put($routes, str_replace('return function ($route) {', $this->openapiRoutes($module), $this->fs->get($routes))); + $routeContent = $this->fs->get($routes); + + if ($routeContent !== false) { + $this->fs->put($routes, str_replace('return function ($route) {', $this->openapiRoutes($module), $routeContent)); + } } if (!$this->fs->isDirectory($modulePath . DS . 'resources' . DS . 'openapi')) { @@ -169,6 +173,11 @@ private function generateOpenapiSpecification(string $module): void $openApi = Generator::scan([$annotationPath]); + if ($openApi === null) { + $this->error('Failed to generate OpenAPI specification.'); + return; + } + $this->fs->put($specPath, $openApi->toJson()); $this->info('OpenAPI specification generated successfully.'); diff --git a/src/Console/Commands/ResourceCacheClearCommand.php b/src/Console/Commands/ResourceCacheClearCommand.php index 3705fe9b..631b135f 100644 --- a/src/Console/Commands/ResourceCacheClearCommand.php +++ b/src/Console/Commands/ResourceCacheClearCommand.php @@ -17,6 +17,7 @@ namespace Quantum\Console\Commands; use Quantum\Storage\Factories\FileSystemFactory; +use Quantum\Loader\Exceptions\LoaderException; use Quantum\Config\Exceptions\ConfigException; use Quantum\App\Exceptions\BaseException; use Quantum\Di\Exceptions\DiException; @@ -73,7 +74,16 @@ class ResourceCacheClearCommand extends QtCommand protected string $cacheDir = ''; - protected ?FileSystem $fs = null; + protected FileSystem $fs; + + /** + * @throws BaseException|ReflectionException + */ + public function __construct() + { + parent::__construct(); + $this->fs = FileSystemFactory::get(); + } /** * @throws BaseException|ReflectionException @@ -89,8 +99,6 @@ public function exec(): void return; } - $this->fs = FileSystemFactory::get(); - if (!$this->fs->isDirectory($this->cacheDir)) { $this->error('Cache directory does not exist or is not accessible.'); return; @@ -127,7 +135,7 @@ private function importModules(): void /** * @throws ConfigException * @throws DiException - * @throws ReflectionException + * @throws ReflectionException|LoaderException */ private function importConfig(): void { diff --git a/src/Console/Commands/RouteListCommand.php b/src/Console/Commands/RouteListCommand.php index 2f841c52..a1db75ec 100644 --- a/src/Console/Commands/RouteListCommand.php +++ b/src/Console/Commands/RouteListCommand.php @@ -93,7 +93,7 @@ static function (Route $route) use ($module): bool { $rows[] = $this->composeTableRow($route, 50); } - $table = new Table($this->output); + $table = new Table($this->resolveOutput()); $table->setHeaderTitle('Routes') ->setHeaders(['MODULE', 'METHOD', 'URI', 'ACTION', 'MIDDLEWARE']) diff --git a/src/Console/Commands/ServeCommand.php b/src/Console/Commands/ServeCommand.php index f30d11e3..61f5d351 100644 --- a/src/Console/Commands/ServeCommand.php +++ b/src/Console/Commands/ServeCommand.php @@ -229,7 +229,7 @@ protected function waitUntilServerIsReady(string $host, int $port, $process): vo */ protected function waitForProcess($process): void { - while (proc_get_status($process)['running']) { + while (($status = proc_get_status($process)) !== false && $status['running']) { usleep(200_000); } diff --git a/src/Console/QtCommand.php b/src/Console/QtCommand.php index d78cd21b..4ec38fa0 100644 --- a/src/Console/QtCommand.php +++ b/src/Console/QtCommand.php @@ -23,6 +23,7 @@ use Symfony\Component\Console\Input\InputOption; use Quantum\Console\Contracts\CommandInterface; use Symfony\Component\Console\Command\Command; +use RuntimeException; /** * Class QtCommand @@ -85,7 +86,7 @@ public function __construct() */ public function getArgument(?string $key = null) { - return $this->input->getArgument($key) ?? ''; + return $this->resolveInput()->getArgument($key ?? '') ?? ''; } /** @@ -94,7 +95,7 @@ public function getArgument(?string $key = null) */ public function getOption(?string $key = null) { - return $this->input->getOption($key) ?? ''; + return $this->resolveInput()->getOption($key ?? '') ?? ''; } /** @@ -102,7 +103,7 @@ public function getOption(?string $key = null) */ public function output(string $message): void { - $this->output->writeln($message); + $this->resolveOutput()->writeln($message); } /** @@ -110,7 +111,7 @@ public function output(string $message): void */ public function info(string $message): void { - $this->output->writeln("$message"); + $this->resolveOutput()->writeln("$message"); } /** @@ -118,7 +119,7 @@ public function info(string $message): void */ public function comment(string $message): void { - $this->output->writeln("$message"); + $this->resolveOutput()->writeln("$message"); } /** @@ -126,7 +127,7 @@ public function comment(string $message): void */ public function question(string $message): void { - $this->output->writeln("$message"); + $this->resolveOutput()->writeln("$message"); } /** @@ -138,7 +139,7 @@ public function confirm(string $message): bool $question = new ConfirmationQuestion($message . ' [y/N] ', false); - return (bool) $helper->ask($this->input, $this->output, $question); + return (bool) $helper->ask($this->resolveInput(), $this->resolveOutput(), $question); } /** @@ -146,7 +147,25 @@ public function confirm(string $message): bool */ public function error(string $message): void { - $this->output->writeln("$message"); + $this->resolveOutput()->writeln("$message"); + } + + protected function resolveInput(): InputInterface + { + if ($this->input === null) { + throw new RuntimeException('Input is not set.'); + } + + return $this->input; + } + + protected function resolveOutput(): OutputInterface + { + if ($this->output === null) { + throw new RuntimeException('Output is not set.'); + } + + return $this->output; } /** diff --git a/src/Cron/CronLock.php b/src/Cron/CronLock.php index 99e98daa..5e55841f 100644 --- a/src/Cron/CronLock.php +++ b/src/Cron/CronLock.php @@ -53,13 +53,15 @@ public function __construct(string $taskName, ?string $lockDirectory = null, ?in public function acquire(): bool { - $this->lockHandle = fopen($this->lockFile, 'c+'); - if ($this->lockHandle === false) { + $handle = fopen($this->lockFile, 'c+'); + if ($handle === false) { $this->lockHandle = null; $this->ownsLock = false; return false; } + $this->lockHandle = $handle; + if (!flock($this->lockHandle, LOCK_EX | LOCK_NB)) { fclose($this->lockHandle); $this->lockHandle = null; diff --git a/src/Cron/CronTask.php b/src/Cron/CronTask.php index 33a2aa08..de942111 100644 --- a/src/Cron/CronTask.php +++ b/src/Cron/CronTask.php @@ -29,7 +29,7 @@ class CronTask implements CronTaskInterface /** * Cron expression instance */ - private ?CronExpression $cronExpression = null; + private CronExpression $cronExpression; /** * Task name @@ -63,7 +63,7 @@ public function __construct(string $name, string $expression, callable $callback */ public function getExpression(): string { - return $this->cronExpression->getExpression(); + return $this->cronExpression->getExpression() ?? ''; } /** diff --git a/src/Cron/Schedule.php b/src/Cron/Schedule.php index f8bd068d..b4a584ba 100644 --- a/src/Cron/Schedule.php +++ b/src/Cron/Schedule.php @@ -350,7 +350,7 @@ public function at(string $time): self $minute = (int) $minute; // Replace hour and minute in existing expression - $parts = explode(' ', $this->expression); + $parts = explode(' ', $this->expression ?? ''); $parts[0] = (string) $minute; $parts[1] = (string) $hour; $this->expression = implode(' ', $parts); diff --git a/src/Csrf/Csrf.php b/src/Csrf/Csrf.php index b7cb156e..e4614afe 100644 --- a/src/Csrf/Csrf.php +++ b/src/Csrf/Csrf.php @@ -87,7 +87,7 @@ public function generateToken(string $key): ?string * Checks the token * @throws CsrfException */ - public function checkToken(?Request $request): bool + public function checkToken(Request $request): bool { if (!$request->has(self::TOKEN_KEY)) { throw CsrfException::tokenNotFound(); diff --git a/src/Database/Adapters/Idiorm/IdiormDbal.php b/src/Database/Adapters/Idiorm/IdiormDbal.php index 7a0f384f..1106fd41 100644 --- a/src/Database/Adapters/Idiorm/IdiormDbal.php +++ b/src/Database/Adapters/Idiorm/IdiormDbal.php @@ -67,7 +67,7 @@ class IdiormDbal implements DbalInterface, RelationalInterface /** * Associated model name - * @var string + * @var string|null */ private $modelName; @@ -202,15 +202,18 @@ public function getTable(): string */ public function getOrmModel(): ORM { - if (!$this->ormModel) { + $model = $this->ormModel; + + if (!$model) { if (!self::getConnection()) { throw DatabaseException::missingConfig('database'); } - $this->ormModel = (self::$ormClass)::for_table($this->table)->use_id_column($this->idColumn); + $model = (self::$ormClass)::for_table($this->table)->use_id_column($this->idColumn); + $this->ormModel = $model; } - return $this->ormModel; + return $model; } /** @@ -227,7 +230,7 @@ public function getForeignKeys(): array */ public function getModelName(): string { - return $this->modelName; + return $this->modelName ?? ''; } /** diff --git a/src/Database/Adapters/Idiorm/IdiormPatch.php b/src/Database/Adapters/Idiorm/IdiormPatch.php index fec4ca40..008635ae 100644 --- a/src/Database/Adapters/Idiorm/IdiormPatch.php +++ b/src/Database/Adapters/Idiorm/IdiormPatch.php @@ -25,7 +25,7 @@ */ class IdiormPatch extends ORM { - private ?object $ormModel = null; + private ?ORM $ormModel = null; private static ?IdiormPatch $instance = null; @@ -44,7 +44,7 @@ public static function getInstance(): IdiormPatch /** * Set ORM Object */ - public function use(object $ormModel): IdiormPatch + public function use(ORM $ormModel): IdiormPatch { $this->ormModel = $ormModel; return $this; diff --git a/src/Database/Adapters/Idiorm/Statements/Query.php b/src/Database/Adapters/Idiorm/Statements/Query.php index 68ce30bf..15557a98 100644 --- a/src/Database/Adapters/Idiorm/Statements/Query.php +++ b/src/Database/Adapters/Idiorm/Statements/Query.php @@ -17,6 +17,7 @@ namespace Quantum\Database\Adapters\Idiorm\Statements; use Quantum\Database\Exceptions\DatabaseException; +use PDOStatement; use PDOException; /** @@ -118,8 +119,13 @@ public static function fetchColumns(string $table): array self::query('SELECT * FROM ' . $table); $statement = self::lastStatement(); - for ($i = 0; $i < $statement->columnCount(); $i++) { - $columns[] = $statement->getColumnMeta($i)['name']; + if ($statement instanceof PDOStatement) { + for ($i = 0; $i < $statement->columnCount(); $i++) { + $meta = $statement->getColumnMeta($i); + if (is_array($meta)) { + $columns[] = $meta['name']; + } + } } return $columns; diff --git a/src/Database/Adapters/Idiorm/Statements/Result.php b/src/Database/Adapters/Idiorm/Statements/Result.php index 61187cc1..01fb1071 100644 --- a/src/Database/Adapters/Idiorm/Statements/Result.php +++ b/src/Database/Adapters/Idiorm/Statements/Result.php @@ -32,11 +32,14 @@ trait Result */ public function get(): array { + $results = $this->getOrmModel()->find_many(); + $items = is_array($results) ? $results : iterator_to_array($results); + return array_map(function ($element): object { $item = clone $this; $item->updateOrmModel($element); return $item; - }, $this->getOrmModel()->find_many()); + }, $items); } /** @@ -110,7 +113,7 @@ public function asArray(): array * @param array $result * @return array */ - public function setHidden($result): array + public function setHidden(array $result): array { return array_diff_key($result, array_flip($this->hidden)); } diff --git a/src/Database/Adapters/Sleekdb/SleekDbal.php b/src/Database/Adapters/Sleekdb/SleekDbal.php index 7127b7ca..9e2e6af5 100644 --- a/src/Database/Adapters/Sleekdb/SleekDbal.php +++ b/src/Database/Adapters/Sleekdb/SleekDbal.php @@ -56,12 +56,12 @@ class SleekDbal implements DbalInterface protected $modifiedFields = []; /** - * @var array> + * @var array|string> */ protected array $criterias = []; /** - * @var array> + * @var array> */ protected array $havings = []; @@ -263,8 +263,8 @@ public function getOrmModel(): Store */ public function updateOrmModel(?array $modelData): void { - $this->data = $modelData; - $this->modifiedFields = $modelData; + $this->data = $modelData ?? []; + $this->modifiedFields = $modelData ?? []; $this->isNew = false; } @@ -292,12 +292,15 @@ public function truncate(): bool */ public function getBuilder(): QueryBuilder { - if (!$this->queryBuilder) { - $this->queryBuilder = $this->getOrmModel()->createQueryBuilder(); + $builder = $this->queryBuilder; + + if (!$builder) { + $builder = $this->getOrmModel()->createQueryBuilder(); + $this->queryBuilder = $builder; } if ($this->selected !== []) { - $this->queryBuilder->select($this->selected); + $builder->select($this->selected); } if ($this->joins !== []) { @@ -305,30 +308,30 @@ public function getBuilder(): QueryBuilder } if ($this->criterias !== []) { - $this->queryBuilder->where($this->criterias); + $builder->where($this->criterias); } if ($this->havings !== []) { - $this->queryBuilder->having($this->havings); + $builder->having($this->havings); } if ($this->grouped !== []) { - $this->queryBuilder->groupBy($this->grouped); + $builder->groupBy($this->grouped); } if ($this->ordered !== []) { - $this->queryBuilder->orderBy($this->ordered); + $builder->orderBy($this->ordered); } if ($this->offset) { - $this->queryBuilder->skip($this->offset); + $builder->skip($this->offset); } if ($this->limit) { - $this->queryBuilder->limit($this->limit); + $builder->limit($this->limit); } - return $this->queryBuilder; + return $builder; } /** @@ -345,7 +348,7 @@ public function getForeignKeys(): array */ public function getModelName(): string { - return $this->modelName; + return $this->modelName ?? ''; } /** diff --git a/src/Database/Adapters/Sleekdb/Statements/Join.php b/src/Database/Adapters/Sleekdb/Statements/Join.php index 34563cf7..f70dbfbc 100644 --- a/src/Database/Adapters/Sleekdb/Statements/Join.php +++ b/src/Database/Adapters/Sleekdb/Statements/Join.php @@ -23,6 +23,7 @@ use Quantum\Database\Enums\Relation; use Quantum\Model\DbModel; use SleekDB\QueryBuilder; +use RuntimeException; /** * Trait Join @@ -50,6 +51,10 @@ public function joinTo(DbModel $model, bool $switch = true): DbalInterface private function applyJoins(): void { if (!empty($this->joins)) { + if ($this->queryBuilder === null) { + throw new RuntimeException('Cannot apply joins without an initialized query builder.'); + } + $this->applyJoin($this->queryBuilder, $this, $this->joins[0]); } } @@ -64,6 +69,10 @@ private function applyJoin(QueryBuilder $queryBuilder, SleekDbal $currentItem, a $modelToJoin = unserialize($nextItem['model']); $switch = $nextItem['switch']; + if (!$modelToJoin instanceof DbModel) { + throw new RuntimeException('Failed to unserialize join model.'); + } + $queryBuilder->join(function ($item) use ($currentItem, $modelToJoin, $switch, $level) { $sleekModel = new self( diff --git a/src/Database/Adapters/Sleekdb/Statements/Model.php b/src/Database/Adapters/Sleekdb/Statements/Model.php index d752e13e..5bd430a7 100644 --- a/src/Database/Adapters/Sleekdb/Statements/Model.php +++ b/src/Database/Adapters/Sleekdb/Statements/Model.php @@ -100,6 +100,7 @@ public function delete(): bool */ public function deleteMany(): bool { - return $this->getBuilder()->getQuery()->delete(); + $result = $this->getBuilder()->getQuery()->delete(); + return is_bool($result) ? $result : $result !== 0; } } diff --git a/src/Database/Adapters/Sleekdb/Statements/Result.php b/src/Database/Adapters/Sleekdb/Statements/Result.php index cf34687d..e1bbc70f 100644 --- a/src/Database/Adapters/Sleekdb/Statements/Result.php +++ b/src/Database/Adapters/Sleekdb/Statements/Result.php @@ -143,7 +143,7 @@ public function asArray(): array * @param array $result * @return array */ - public function setHidden($result): array + public function setHidden(array $result): array { return array_diff_key($result, array_flip($this->hidden)); } diff --git a/src/Di/Di.php b/src/Di/Di.php index f1c77d9c..03798c07 100644 --- a/src/Di/Di.php +++ b/src/Di/Di.php @@ -31,17 +31,17 @@ class Di { /** - * @var array + * @var array */ private static array $dependencies = []; /** - * @var array + * @var array */ private static array $container = []; /** - * @var array + * @var array */ private static array $resolving = []; @@ -219,6 +219,7 @@ private static function resolve(string $abstract, array $args = [], bool $single /** * Instantiates the dependency + * @param class-string $concrete * @param array $args * @return mixed * @throws ReflectionException|DiException diff --git a/src/Di/Exceptions/DiException.php b/src/Di/Exceptions/DiException.php index 63fe899e..034c3e45 100644 --- a/src/Di/Exceptions/DiException.php +++ b/src/Di/Exceptions/DiException.php @@ -68,7 +68,7 @@ public static function circularDependency(string $chain): DiException public static function invalidCallable(?string $entry = null): DiException { return new self( - _message(ExceptionMessages::INVALID_CALLABLE, [$entry]), + _message(ExceptionMessages::INVALID_CALLABLE, [$entry ?? '']), E_ERROR ); } diff --git a/src/Encryption/Adapters/AsymmetricEncryptionAdapter.php b/src/Encryption/Adapters/AsymmetricEncryptionAdapter.php index f05b26c4..d42d0104 100644 --- a/src/Encryption/Adapters/AsymmetricEncryptionAdapter.php +++ b/src/Encryption/Adapters/AsymmetricEncryptionAdapter.php @@ -103,7 +103,14 @@ private function generateKeyPair(): array } openssl_pkey_export($resource, $privateKey); - $publicKey = openssl_pkey_get_details($resource)['key']; + + $details = openssl_pkey_get_details($resource); + + if ($details === false) { + throw CryptorException::missingConfig('openssl.cnf'); + } + + $publicKey = $details['key']; return [ 'private' => $privateKey, diff --git a/src/Encryption/Adapters/SymmetricEncryptionAdapter.php b/src/Encryption/Adapters/SymmetricEncryptionAdapter.php index 1bcdf44e..5d1aed22 100644 --- a/src/Encryption/Adapters/SymmetricEncryptionAdapter.php +++ b/src/Encryption/Adapters/SymmetricEncryptionAdapter.php @@ -60,6 +60,10 @@ public function encrypt(string $plain): string $encrypted = openssl_encrypt($plain, self::CIPHER_METHOD, $this->appKey, 0, $iv); + if ($encrypted === false) { + throw CryptorException::invalidCipher(); + } + return base64_encode(base64_encode($encrypted) . '::' . base64_encode($iv)); } @@ -82,7 +86,13 @@ public function decrypt(string $encrypted): string $encryptedData = base64_decode($data[0]); $iv = base64_decode($data[1]); - return openssl_decrypt($encryptedData, self::CIPHER_METHOD, $this->appKey, 0, $iv); + $decrypted = openssl_decrypt($encryptedData, self::CIPHER_METHOD, $this->appKey, 0, $iv); + + if ($decrypted === false) { + throw CryptorException::invalidCipher(); + } + + return $decrypted; } /** @@ -91,6 +101,17 @@ public function decrypt(string $encrypted): string private function generateIV(): string { $length = openssl_cipher_iv_length(self::CIPHER_METHOD); - return openssl_random_pseudo_bytes($length); + + if ($length === false) { + throw CryptorException::invalidCipher(); + } + + $bytes = openssl_random_pseudo_bytes($length); + + if ($bytes === false) { + throw CryptorException::invalidCipher(); + } + + return $bytes; } } diff --git a/src/Environment/Environment.php b/src/Environment/Environment.php index e8ef37a2..ce4afe5c 100644 --- a/src/Environment/Environment.php +++ b/src/Environment/Environment.php @@ -170,6 +170,11 @@ public function updateRow(string $key, ?string $value): void if ($row) { $envFileContent = fs()->get($envFilePath); + + if (!is_string($envFileContent)) { + throw EnvException::fileNotFound($this->envFile); + } + $envFileContent = preg_replace('/^' . preg_quote($row, '/') . '/m', $key . '=' . $value, $envFileContent); fs()->put($envFilePath, (string) $envFileContent); diff --git a/src/Http/Request/HttpRequest.php b/src/Http/Request/HttpRequest.php index 00eb292d..4369f68c 100644 --- a/src/Http/Request/HttpRequest.php +++ b/src/Http/Request/HttpRequest.php @@ -174,7 +174,7 @@ public static function setMethod(string $method): void */ public static function isMethod(string $method): bool { - return strcasecmp($method, self::$__method) === 0; + return strcasecmp($method, self::$__method ?? '') === 0; } /** diff --git a/src/Http/Traits/Request/Params.php b/src/Http/Traits/Request/Params.php index c1adb1d5..6c050ce6 100644 --- a/src/Http/Traits/Request/Params.php +++ b/src/Http/Traits/Request/Params.php @@ -32,7 +32,7 @@ trait Params * Request content type * @var string|null */ - private static $__contentType; + private static ?string $__contentType; /** * Gets the GET params. @@ -57,7 +57,7 @@ private static function postParams(): array return []; } - return filter_input_array(INPUT_POST) ?? []; + return filter_input_array(INPUT_POST) ?: []; } /** @@ -91,7 +91,7 @@ private static function urlEncodedParams(): array parse_str(urldecode(self::getRawInput()), $result); - return $result; + return $result; /** @phpstan-ignore return.type */ } /** @@ -119,6 +119,6 @@ private static function getRawInputParams(): array */ private static function getRawInput(): string { - return file_get_contents('php://input'); + return file_get_contents('php://input') ?: ''; } } diff --git a/src/Http/Traits/Request/RawInput.php b/src/Http/Traits/Request/RawInput.php index 69be8b12..de81eff7 100644 --- a/src/Http/Traits/Request/RawInput.php +++ b/src/Http/Traits/Request/RawInput.php @@ -75,6 +75,11 @@ private static function getBoundary(): ?string private static function getBlocks(string $boundary, string $rawInput): array { $result = preg_split("/-+$boundary/", $rawInput); + + if ($result === false) { + return []; + } + array_pop($result); return $result; @@ -105,12 +110,14 @@ private static function processBlocks(array $blocks): array switch ($type) { case 'file': - [$nameParam, $file] = self::getParsedFile($block); + $parsed = self::getParsedFile($block); - if (!$file) { + if ($parsed === null) { continue 2; } + [$nameParam, $file] = $parsed; + self::addFileToCollection($files, $nameParam, $file); break; @@ -174,12 +181,12 @@ private static function getParsedStream(string $block): array { preg_match('/name=\"([^\"]*)\".*stream[\n|\r]+([^\n\r].*)?$/s', $block, $match); - return [$match[1] => $match[2] ?? '']; + return [($match[1] ?? '') => $match[2] ?? '']; } /** * Gets the parsed file - * @return array|null + * @return array{string, UploadedFile}|null * @throws BaseException * @throws ConfigException * @throws DiException @@ -271,7 +278,7 @@ private static function parseHeaders(string $rawHeaders): array $filename = '-unknown-'; $contentType = ContentType::OCTET_STREAM; - $rawHeaders = preg_replace("/\r\n|\r|\n/", "\n", $rawHeaders); + $rawHeaders = preg_replace("/\r\n|\r|\n/", "\n", $rawHeaders) ?? $rawHeaders; $lines = explode("\n", $rawHeaders); foreach ($lines as $line) { diff --git a/src/Http/Traits/Request/Url.php b/src/Http/Traits/Request/Url.php index 62c962da..87876039 100644 --- a/src/Http/Traits/Request/Url.php +++ b/src/Http/Traits/Request/Url.php @@ -129,7 +129,8 @@ public static function getAllSegments(): array return ['zero_segment']; } - $segments = explode('/', trim(parse_url(self::$__uri)['path'], '/')); + $parsed = parse_url(self::$__uri); + $segments = explode('/', trim(is_array($parsed) ? ($parsed['path'] ?? '') : '', '/')); array_unshift($segments, 'zero_segment'); return $segments; } diff --git a/src/Http/Traits/Response/Body.php b/src/Http/Traits/Response/Body.php index b34fe07a..b322efb5 100644 --- a/src/Http/Traits/Response/Body.php +++ b/src/Http/Traits/Response/Body.php @@ -198,9 +198,15 @@ private static function arrayToXML(array $arr): string self::composeXML($arr, $simpleXML); $dom = new DOMDocument(); - $dom->loadXML($simpleXML->asXML()); + $xml = $simpleXML->asXML(); + + if ($xml === false) { + return ''; + } + + $dom->loadXML($xml); $dom->formatOutput = true; - return $dom->saveXML(); + return $dom->saveXML() ?: ''; } /** @@ -248,7 +254,7 @@ private static function composeXML(array $arr, SimpleXMLElement &$simpleXML): vo */ private static function formatJson(): string { - return json_encode(self::all(), JSON_UNESCAPED_UNICODE); + return json_encode(self::all(), JSON_UNESCAPED_UNICODE) ?: ''; } /** diff --git a/src/HttpClient/HttpClient.php b/src/HttpClient/HttpClient.php index 59e257b6..98431f5c 100644 --- a/src/HttpClient/HttpClient.php +++ b/src/HttpClient/HttpClient.php @@ -163,6 +163,8 @@ public function getData() /** * Checks if the request is multi cURL + * @phpstan-assert-if-true MultiCurl $this->client + * @phpstan-assert-if-false Curl $this->client */ public function isMultiRequest(): bool { @@ -317,10 +319,12 @@ public function __call(string $method, array $arguments): HttpClient } /** - * @throws ErrorException + * @throws ErrorException|BaseException */ private function startSingleRequest(): void { + $this->ensureSingleRequest(); + $this->client->setOpt(CURLOPT_CUSTOMREQUEST, $this->method); if ($this->data) { @@ -366,6 +370,7 @@ private function formatHeaders(CaseInsensitiveArray $headers): array /** * @throws BaseException + * @phpstan-assert Curl $this->client */ private function ensureSingleRequest(): void { diff --git a/src/Lang/Lang.php b/src/Lang/Lang.php index bb817e0b..c473177f 100644 --- a/src/Lang/Lang.php +++ b/src/Lang/Lang.php @@ -81,7 +81,7 @@ public function load(): void /** * Get translation by key - * @param array|null $params + * @param array|string|null $params */ public function getTranslation(string $key, $params = null): ?string { diff --git a/src/Lang/Translator.php b/src/Lang/Translator.php index 659b85f3..3d5202eb 100644 --- a/src/Lang/Translator.php +++ b/src/Lang/Translator.php @@ -86,6 +86,10 @@ public function loadTranslations(): void */ private function loadFiles(array $files): void { + if ($this->translations === null) { + return; + } + foreach ($files as $file) { $fileName = fs()->fileName($file); @@ -97,7 +101,7 @@ private function loadFiles(array $files): void /** * Get translation by key - * @param array|string|null $params + * @param array|string|null $params */ public function get(string $key, $params = null): string { diff --git a/src/Loader/Loader.php b/src/Loader/Loader.php index dab31beb..83eeae5f 100644 --- a/src/Loader/Loader.php +++ b/src/Loader/Loader.php @@ -79,7 +79,13 @@ public function set(string $property, $value): Loader */ public function loadDir(string $dir): void { - foreach (glob($dir . DS . '*.php') as $filename) { + $files = glob($dir . DS . '*.php'); + + if (!is_array($files)) { + return; + } + + foreach ($files as $filename) { require_once $filename; } } @@ -106,7 +112,7 @@ public function fileExists(): bool } if ($this->hierarchical) { - $filePath = App::getBaseDir() . DS . 'shared' . DS . strtolower($this->pathPrefix) . DS . $this->fileName . '.php'; + $filePath = App::getBaseDir() . DS . 'shared' . DS . strtolower($this->pathPrefix ?? '') . DS . $this->fileName . '.php'; return file_exists($filePath); } @@ -123,13 +129,13 @@ public function getFilePath(): string if (!file_exists($filePath)) { if ($this->hierarchical) { - $filePath = App::getBaseDir() . DS . 'shared' . DS . strtolower($this->pathPrefix) . DS . $this->fileName . '.php'; + $filePath = App::getBaseDir() . DS . 'shared' . DS . strtolower($this->pathPrefix ?? '') . DS . $this->fileName . '.php'; if (!file_exists($filePath)) { - throw new LoaderException(_message($this->exceptionMessage, $this->fileName)); + throw new LoaderException(_message($this->exceptionMessage, $this->fileName ?? '')); } } else { - throw new LoaderException(_message($this->exceptionMessage, $this->fileName)); + throw new LoaderException(_message($this->exceptionMessage, $this->fileName ?? '')); } } diff --git a/src/Logger/Factories/LoggerFactory.php b/src/Logger/Factories/LoggerFactory.php index 5a935a92..1feaf289 100644 --- a/src/Logger/Factories/LoggerFactory.php +++ b/src/Logger/Factories/LoggerFactory.php @@ -16,6 +16,7 @@ namespace Quantum\Logger\Factories; +use Quantum\Logger\Contracts\ReportableInterface; use Quantum\Logger\Exceptions\LoggerException; use Quantum\Config\Exceptions\ConfigException; use Quantum\Logger\Adapters\MessageAdapter; @@ -84,9 +85,17 @@ public static function get(?string $adapter = null): Logger private static function createInstance(string $adapterClass, string $adapter): Logger { - return $adapter === LoggerType::MESSAGE - ? new Logger(new MessageAdapter()) - : new Logger(new $adapterClass(config()->get('logging.' . $adapter))); + if ($adapter === LoggerType::MESSAGE) { + return new Logger(new MessageAdapter()); + } + + $adapterInstance = new $adapterClass(config()->get('logging.' . $adapter)); + + if (!$adapterInstance instanceof ReportableInterface) { + throw LoggerException::adapterNotSupported($adapter); + } + + return new Logger($adapterInstance); } /** diff --git a/src/Mailer/Adapters/MailgunAdapter.php b/src/Mailer/Adapters/MailgunAdapter.php index e6c56fda..e4dccb62 100644 --- a/src/Mailer/Adapters/MailgunAdapter.php +++ b/src/Mailer/Adapters/MailgunAdapter.php @@ -38,6 +38,8 @@ class MailgunAdapter implements MailerInterface private string $apiUrl = 'https://api.mailgun.net/v3/'; + private HttpClient $httpClient; + /** * @var array */ @@ -58,7 +60,7 @@ public function __construct(array $params) /** * Prepares the data */ - private function prepare(): void + protected function prepare(): void { $this->data['from'] = $this->from['name'] . ' ' . $this->from['email']; @@ -84,7 +86,7 @@ private function prepare(): void } } - private function sendEmail(): bool + protected function sendEmail(): bool { try { $this->httpClient @@ -102,4 +104,13 @@ private function sendEmail(): bool return false; } } + + /** + * @return array + */ + protected function getTransportErrors(): array + { + return $this->httpClient->getErrors(); + } + } diff --git a/src/Mailer/Adapters/MandrillAdapter.php b/src/Mailer/Adapters/MandrillAdapter.php index c75368c4..1b7c8797 100644 --- a/src/Mailer/Adapters/MandrillAdapter.php +++ b/src/Mailer/Adapters/MandrillAdapter.php @@ -36,6 +36,8 @@ class MandrillAdapter implements MailerInterface private string $apiUrl = 'https://mandrillapp.com/api/1.0/messages/send.json'; + private HttpClient $httpClient; + /** * @var array */ @@ -55,7 +57,7 @@ public function __construct(array $params) /** * Prepares the data */ - private function prepare(): void + protected function prepare(): void { $message = []; @@ -84,7 +86,7 @@ private function prepare(): void $this->data['message'] = $message; } - private function sendEmail(): bool + protected function sendEmail(): bool { try { $this->httpClient @@ -98,4 +100,12 @@ private function sendEmail(): bool return false; } } + + /** + * @return array + */ + protected function getTransportErrors(): array + { + return $this->httpClient->getErrors(); + } } diff --git a/src/Mailer/Adapters/ResendAdapter.php b/src/Mailer/Adapters/ResendAdapter.php index d66c7b5d..51400ae0 100644 --- a/src/Mailer/Adapters/ResendAdapter.php +++ b/src/Mailer/Adapters/ResendAdapter.php @@ -31,6 +31,8 @@ class ResendAdapter implements MailerInterface public string $name = 'Resend'; + private HttpClient $httpClient; + /** * @var string|null */ @@ -56,7 +58,7 @@ public function __construct(array $params) /** * Prepares the data */ - private function prepare(): void + protected function prepare(): void { $fromName = $this->from['name'] ?? null; @@ -81,7 +83,7 @@ private function prepare(): void } } - private function sendEmail(): bool + protected function sendEmail(): bool { try { $this->httpClient @@ -99,4 +101,12 @@ private function sendEmail(): bool return false; } } + + /** + * @return array + */ + protected function getTransportErrors(): array + { + return $this->httpClient->getErrors(); + } } diff --git a/src/Mailer/Adapters/SendgridAdapter.php b/src/Mailer/Adapters/SendgridAdapter.php index 073d1850..8201d7a0 100644 --- a/src/Mailer/Adapters/SendgridAdapter.php +++ b/src/Mailer/Adapters/SendgridAdapter.php @@ -38,6 +38,8 @@ class SendgridAdapter implements MailerInterface private string $apiUrl = 'https://api.sendgrid.com/v3/mail/send'; + private HttpClient $httpClient; + /** * @var array */ @@ -57,7 +59,7 @@ public function __construct(array $params) /** * Prepares the data */ - public function prepare(): void + protected function prepare(): void { $this->data['from'] = $this->from; @@ -85,7 +87,7 @@ public function prepare(): void } } - private function sendEmail(): bool + protected function sendEmail(): bool { try { $this->httpClient @@ -103,4 +105,12 @@ private function sendEmail(): bool return false; } } + + /** + * @return array + */ + protected function getTransportErrors(): array + { + return $this->httpClient->getErrors(); + } } diff --git a/src/Mailer/Adapters/SendinblueAdapter.php b/src/Mailer/Adapters/SendinblueAdapter.php index 7e7c1da9..bf0e9368 100644 --- a/src/Mailer/Adapters/SendinblueAdapter.php +++ b/src/Mailer/Adapters/SendinblueAdapter.php @@ -38,6 +38,8 @@ class SendinblueAdapter implements MailerInterface private string $apiUrl = 'https://api.sendinblue.com/v3/smtp/email'; + private HttpClient $httpClient; + /** * @var array */ @@ -57,7 +59,7 @@ public function __construct(array $params) /** * Prepares the data */ - private function prepare(): void + protected function prepare(): void { $this->data['sender'] = $this->from; $this->data['to'] = $this->addresses; @@ -77,7 +79,7 @@ private function prepare(): void } } - private function sendEmail(): bool + protected function sendEmail(): bool { try { $this->httpClient @@ -96,4 +98,12 @@ private function sendEmail(): bool return false; } } + + /** + * @return array + */ + protected function getTransportErrors(): array + { + return $this->httpClient->getErrors(); + } } diff --git a/src/Mailer/Adapters/SmtpAdapter.php b/src/Mailer/Adapters/SmtpAdapter.php index 98648670..ba313c03 100644 --- a/src/Mailer/Adapters/SmtpAdapter.php +++ b/src/Mailer/Adapters/SmtpAdapter.php @@ -33,6 +33,8 @@ class SmtpAdapter implements MailerInterface public string $name = 'SMTP'; + private PHPMailer $mailer; + /** * SmtpAdapter constructor * @param array $params @@ -69,7 +71,7 @@ public function setReplay(string $email, ?string $name = null): SmtpAdapter /** * Gets "Reply-To" addresses - * @return array + * @return array */ public function getReplays(): array { @@ -94,7 +96,7 @@ public function setCC(string $email, ?string $name = null): SmtpAdapter /** * Gets "CC" addresses - * @return array + * @return array */ public function getCCs(): array { @@ -119,7 +121,7 @@ public function setBCC(string $email, ?string $name = null): SmtpAdapter /** * Get "BCC" addresses - * @return array + * @return array */ public function getBCCs(): array { @@ -134,13 +136,12 @@ public function getBCCs(): array public function setAttachment(string $attachment): SmtpAdapter { $this->attachments[] = $attachment; - ; return $this; } /** * Gets the attachments - * @return array + * @return array */ public function getAttachments(): array { @@ -165,13 +166,61 @@ public function setStringAttachment(string $content, string $filename): SmtpAdap /** * Gets the string attachments - * @return array + * @return array */ public function getStringAttachments(): array { return $this->stringAttachments; } + /** + * @throws Exception + */ + protected function resolveMessageId(): string + { + preg_match('/<(.*?)@/', $this->mailer->getLastMessageID(), $matches); + return $matches[1] ?? bin2hex(random_bytes(16)); + } + + protected function getRenderedMessage(): ?string + { + return $this->mailer->getSentMIMEMessage(); + } + + /** + * @throws \PHPMailer\PHPMailer\Exception + */ + protected function beforeSave(): void + { + $this->mailer->preSend(); + } + + /** + * @return array + */ + protected function getTransportErrors(): array + { + $error = $this->mailer->ErrorInfo; + return $error !== '' ? [$error] : []; + } + + protected function resetTransportState(): void + { + $this->replyToAddresses = []; + $this->ccAddresses = []; + $this->bccAddresses = []; + $this->attachments = []; + $this->stringAttachments = []; + + $this->mailer->clearAddresses(); + $this->mailer->clearCCs(); + $this->mailer->clearBCCs(); + $this->mailer->clearReplyTos(); + $this->mailer->clearAllRecipients(); + $this->mailer->clearAttachments(); + $this->mailer->clearCustomHeaders(); + } + /** * Setups the SMTP * @param array $params @@ -191,7 +240,7 @@ private function setupSmtp(array $params): void * Prepares the data * @throws Exception */ - private function prepare(): void + protected function prepare(): void { $this->mailer->setFrom($this->from['email'], $this->from['name']); @@ -239,7 +288,7 @@ private function fillProperties(string $method, array $fields = []): void /** * @throws \PHPMailer\PHPMailer\Exception */ - private function sendEmail(): bool + protected function sendEmail(): bool { return $this->mailer->send(); } diff --git a/src/Mailer/Contracts/MailerInterface.php b/src/Mailer/Contracts/MailerInterface.php index b25c0c35..46e9aab9 100644 --- a/src/Mailer/Contracts/MailerInterface.php +++ b/src/Mailer/Contracts/MailerInterface.php @@ -40,7 +40,7 @@ public function setAddress(string $email, ?string $name = null): MailerInterface /** * Gets 'To' addresses - * @return array + * @return array */ public function getAddresses(): array; diff --git a/src/Mailer/Factories/MailerFactory.php b/src/Mailer/Factories/MailerFactory.php index 70a43c6b..65a2d293 100644 --- a/src/Mailer/Factories/MailerFactory.php +++ b/src/Mailer/Factories/MailerFactory.php @@ -19,12 +19,13 @@ use Quantum\Mailer\Adapters\SendinblueAdapter; use Quantum\Mailer\Exceptions\MailerException; use Quantum\Config\Exceptions\ConfigException; +use Quantum\Mailer\Contracts\MailerInterface; use Quantum\Mailer\Adapters\MandrillAdapter; use Quantum\Mailer\Adapters\SendgridAdapter; use Quantum\Mailer\Adapters\MailgunAdapter; +use Quantum\Mailer\Adapters\ResendAdapter; use Quantum\App\Exceptions\BaseException; use Quantum\Mailer\Adapters\SmtpAdapter; -use Quantum\Mailer\Adapters\ResendAdapter; use Quantum\Di\Exceptions\DiException; use Quantum\Mailer\Enums\MailerType; use Quantum\Mailer\Mailer; @@ -77,9 +78,18 @@ public static function get(?string $adapter = null): Mailer return self::$instances[$adapter]; } + /** + * @throws MailerException + */ private static function createInstance(string $adapterClass, string $adapter): Mailer { - return new Mailer(new $adapterClass(config()->get('mailer.' . $adapter))); + $adapterInstance = new $adapterClass(config()->get('mailer.' . $adapter)); + + if (!$adapterInstance instanceof MailerInterface) { + throw MailerException::adapterNotSupported($adapter); + } + + return new Mailer($adapterInstance); } /** diff --git a/src/Mailer/Traits/MailerTrait.php b/src/Mailer/Traits/MailerTrait.php index 0f1683e5..9f8d0e0d 100644 --- a/src/Mailer/Traits/MailerTrait.php +++ b/src/Mailer/Traits/MailerTrait.php @@ -18,8 +18,6 @@ use Quantum\Mailer\Contracts\MailerInterface; use Quantum\Di\Exceptions\DiException; -use Quantum\HttpClient\HttpClient; -use PHPMailer\PHPMailer\PHPMailer; use Quantum\Debugger\Debugger; use Quantum\Mailer\MailTrap; use ReflectionException; @@ -39,7 +37,7 @@ trait MailerTrait /** * To addresses - * @var array + * @var array */ private array $addresses = []; @@ -61,40 +59,66 @@ trait MailerTrait */ private ?string $templatePath = null; - protected ?HttpClient $httpClient = null; - - protected ?PHPMailer $mailer = null; - /** * Reply To addresses - * @var array + * @var array */ protected array $replyToAddresses = []; /** * CC addresses - * @var array + * @var array */ protected array $ccAddresses = []; /** * BCC addresses - * @var array + * @var array */ protected array $bccAddresses = []; /** * Email attachments - * @var array + * @var array */ protected array $attachments = []; /** * Email attachments created from string - * @var array + * @var array */ protected array $stringAttachments = []; + abstract protected function prepare(): void; + + abstract protected function sendEmail(): bool; + + /** + * @return array + */ + abstract protected function getTransportErrors(): array; + + /** + * @throws Exception + */ + protected function resolveMessageId(): string + { + return bin2hex(random_bytes(16)); + } + + protected function getRenderedMessage(): ?string + { + return null; + } + + protected function beforeSave(): void + { + } + + protected function resetTransportState(): void + { + } + /** * Sets the 'From' email and the name */ @@ -129,7 +153,7 @@ public function setAddress(string $email, ?string $name = null): MailerInterface /** * Gets 'To' addresses - * @return array + * @return array */ public function getAddresses(): array { @@ -204,8 +228,11 @@ public function send(): bool $this->resetFields(); - if ($this->name !== 'SMTP' && !$sent) { - warning(implode(', ', $this->httpClient->getErrors()), ['tab' => Debugger::MAILS]); + if (!$sent) { + $errors = $this->getTransportErrors(); + if (!empty($errors)) { + warning(implode(', ', $errors), ['tab' => Debugger::MAILS]); + } } return $sent; @@ -221,12 +248,7 @@ public function getMessageId(): ?string return self::$messageId; } - if ($this->name == 'SMTP') { - preg_match('/<(.*?)@/', preg_quote($this->mailer->getLastMessageID()), $matches); - self::$messageId = $matches[1] ?? null; - } else { - self::$messageId = bin2hex(random_bytes(16)); - } + self::$messageId = $this->resolveMessageId(); return self::$messageId; } @@ -237,11 +259,14 @@ public function getMessageId(): ?string */ private function saveEmail(): bool { - if ($this->name == 'SMTP') { - $this->mailer->preSend(); + $this->beforeSave(); + + $messageId = $this->getMessageId(); + if ($messageId === null) { + return false; } - return MailTrap::getInstance()->saveMessage($this->getMessageId(), $this->getMessageContent()); + return MailTrap::getInstance()->saveMessage($messageId, $this->getMessageContent()); } /** @@ -250,6 +275,7 @@ private function saveEmail(): bool private function createFromTemplate(): string { ob_start(); + /** @phpstan-ignore argument.type */ ob_implicit_flush(PHP_VERSION_ID >= 80000 ? false : 0); if (is_array($this->message)) { @@ -258,7 +284,8 @@ private function createFromTemplate(): string require $this->templatePath . '.php'; - return ob_get_clean(); + $content = ob_get_clean(); + return $content !== false ? $content : ''; } /** @@ -267,11 +294,7 @@ private function createFromTemplate(): string */ private function getMessageContent(): string { - if ($this->name == 'SMTP') { - return $this->mailer->getSentMIMEMessage(); - } - - return $this->generateMessage(); + return $this->getRenderedMessage() ?? $this->generateMessage(); } /** @@ -300,7 +323,13 @@ private function generateMessage(): string $message .= 'Content-Type: text/html; charset=UTF-8' . PHP_EOL . PHP_EOL; - return $message . ($this->message . PHP_EOL); + if ($this->templatePath) { + $body = $this->createFromTemplate(); + } else { + $body = is_string($this->message) ? $this->message : ''; + } + + return $message . ($body . PHP_EOL); } /** @@ -314,20 +343,6 @@ private function resetFields(): void $this->message = null; $this->templatePath = null; - if ($this->name == 'SMTP') { - $this->replyToAddresses = []; - $this->ccAddresses = []; - $this->bccAddresses = []; - $this->attachments = []; - $this->stringAttachments = []; - - $this->mailer->clearAddresses(); - $this->mailer->clearCCs(); - $this->mailer->clearBCCs(); - $this->mailer->clearReplyTos(); - $this->mailer->clearAllRecipients(); - $this->mailer->clearAttachments(); - $this->mailer->clearCustomHeaders(); - } + $this->resetTransportState(); } } diff --git a/src/Migration/Enums/ExceptionMessages.php b/src/Migration/Enums/ExceptionMessages.php index abebb558..361c6f80 100644 --- a/src/Migration/Enums/ExceptionMessages.php +++ b/src/Migration/Enums/ExceptionMessages.php @@ -29,4 +29,6 @@ final class ExceptionMessages extends BaseExceptionMessages public const NOT_SUPPORTED_ACTION = 'The action `{%1}`, is not supported'; public const NOTHING_TO_MIGRATE = 'Nothing to migrate'; + + public const INVALID_MIGRATION_CLASS = 'Migration class `{%1}` must extend QtMigration'; } diff --git a/src/Migration/Exceptions/MigrationException.php b/src/Migration/Exceptions/MigrationException.php index 57e4f439..7fab5508 100644 --- a/src/Migration/Exceptions/MigrationException.php +++ b/src/Migration/Exceptions/MigrationException.php @@ -50,4 +50,12 @@ public static function nothingToMigrate(): self E_NOTICE ); } + + public static function invalidMigrationClass(string $className): self + { + return new self( + _message(ExceptionMessages::INVALID_MIGRATION_CLASS, [$className]), + E_ERROR + ); + } } diff --git a/src/Migration/MigrationManager.php b/src/Migration/MigrationManager.php index e316dc01..34e70754 100644 --- a/src/Migration/MigrationManager.php +++ b/src/Migration/MigrationManager.php @@ -115,15 +115,14 @@ public function generateMigration(string $table, string $action): string /** * Applies migrations * @throws BaseException - * @throws ConfigException * @throws DatabaseException - * @throws DiException * @throws LangException * @throws MigrationException */ public function applyMigrations(string $direction, ?int $step = null): ?int { - $databaseDriver = $this->db->getConfigs()['driver']; + $configs = $this->db->getConfigs(); + $databaseDriver = $configs['driver'] ?? ''; if (!in_array($databaseDriver, self::DRIVERS)) { throw MigrationException::driverNotSupported($databaseDriver); @@ -146,7 +145,6 @@ public function applyMigrations(string $direction, ?int $step = null): ?int /** * Runs up migrations * @throws DatabaseException - * @throws LangException * @throws MigrationException */ private function upgrade(): int @@ -155,10 +153,13 @@ private function upgrade(): int $migrationTable = new MigrationTable(); $migrationTable->up($this->tableFactory); } + $this->prepareUpMigrations(); + if (empty($this->migrations)) { throw MigrationException::nothingToMigrate(); } + $migratedEntries = []; foreach ($this->migrations as $migrationFile) { $this->fs->require($migrationFile, true); @@ -167,6 +168,10 @@ private function upgrade(): int $migration = new $migrationClassName(); + if (!$migration instanceof QtMigration) { + throw MigrationException::invalidMigrationClass($migrationClassName); + } + $migration->up($this->tableFactory); $migratedEntries[] = $migrationClassName; @@ -178,7 +183,6 @@ private function upgrade(): int /** * Runs down migrations * @throws DatabaseException - * @throws LangException * @throws MigrationException */ private function downgrade(?int $step): int @@ -202,6 +206,10 @@ private function downgrade(?int $step): int $migration = new $migrationClassName(); + if (!$migration instanceof QtMigration) { + throw MigrationException::invalidMigrationClass($migrationClassName); + } + $migration->down($this->tableFactory); $migratedEntries[] = $migrationClassName; @@ -222,9 +230,11 @@ private function prepareUpMigrations(): void { $migratedEntries = $this->getMigratedEntries(); $migrationFiles = $this->getMigrationFiles(); + if ($migratedEntries === [] && $migrationFiles === []) { throw MigrationException::nothingToMigrate(); } + foreach ($migrationFiles as $timestamp => $migrationFile) { foreach ($migratedEntries as $migratedEntry) { if (pathinfo($migrationFile, PATHINFO_FILENAME) == $migratedEntry['migration']) { diff --git a/src/Migration/MigrationTable.php b/src/Migration/MigrationTable.php index d225d5e6..edefeedc 100644 --- a/src/Migration/MigrationTable.php +++ b/src/Migration/MigrationTable.php @@ -35,7 +35,7 @@ class MigrationTable extends QtMigration * Creates the migrations table * @throws DatabaseException */ - public function up(?TableFactory $tableFactory): void + public function up(TableFactory $tableFactory): void { $table = $tableFactory->create(self::TABLE); $table->addColumn('id', Type::INT, 11)->autoIncrement(); @@ -47,7 +47,7 @@ public function up(?TableFactory $tableFactory): void * Drops the migrations table * @throws DatabaseException */ - public function down(?TableFactory $tableFactory): void + public function down(TableFactory $tableFactory): void { $tableFactory->drop(self::TABLE); } diff --git a/src/Migration/QtMigration.php b/src/Migration/QtMigration.php index 5f1e7564..1bcea4e2 100644 --- a/src/Migration/QtMigration.php +++ b/src/Migration/QtMigration.php @@ -26,13 +26,11 @@ abstract class QtMigration { /** * Upgrades with the specified migration class - * @return void */ - abstract public function up(?TableFactory $tableFactory): void; + abstract public function up(TableFactory $tableFactory): void; /** * Downgrades with the specified migration class - * @return void */ - abstract public function down(?TableFactory $tableFactory): void; + abstract public function down(TableFactory $tableFactory): void; } diff --git a/src/Model/DbModel.php b/src/Model/DbModel.php index 280cb15f..468d6fe2 100644 --- a/src/Model/DbModel.php +++ b/src/Model/DbModel.php @@ -130,10 +130,14 @@ public function first(): ?DbModel */ public function get(): ModelCollection { - $models = array_map( - fn (?DbalInterface $item): ?DbModel => wrapToModel($item, static::class), - $this->getOrmInstance()->get() - ); + $models = []; + + foreach ($this->getOrmInstance()->get() as $item) { + $model = wrapToModel($item, static::class); + if ($model !== null) { + $models[] = $model; + } + } /** @var ModelCollection $collection */ $collection = new ModelCollection($models); diff --git a/src/Model/Factories/ModelFactory.php b/src/Model/Factories/ModelFactory.php index 1924fab5..b0f75189 100644 --- a/src/Model/Factories/ModelFactory.php +++ b/src/Model/Factories/ModelFactory.php @@ -30,6 +30,9 @@ class ModelFactory { /** * Gets the Model + * @template T of Model + * @param class-string $modelClass + * @return T * @throws ModelException */ public static function get(string $modelClass): Model @@ -99,12 +102,18 @@ protected static function createOrmInstance( ): DbalInterface { $ormClass = Database::getInstance()->getOrmClass(); - return new $ormClass( + $instance = new $ormClass( $table, $modelName, $idColumn, $foreignKeys, $hidden ); + + if (!$instance instanceof DbalInterface) { + throw ModelException::notInstanceOf($ormClass, DbalInterface::class); + } + + return $instance; } } diff --git a/src/Model/ModelCollection.php b/src/Model/ModelCollection.php index de755e9f..459f0aca 100644 --- a/src/Model/ModelCollection.php +++ b/src/Model/ModelCollection.php @@ -195,7 +195,10 @@ private function processModels(): void private function validateModel($model): void { if (!$model instanceof Model) { - throw ModelException::notInstanceOf(get_class($model), Model::class); + throw ModelException::notInstanceOf( + is_object($model) ? get_class($model) : gettype($model), + Model::class + ); } } } diff --git a/src/Module/Enums/ExceptionMessages.php b/src/Module/Enums/ExceptionMessages.php index faac113c..4b582546 100644 --- a/src/Module/Enums/ExceptionMessages.php +++ b/src/Module/Enums/ExceptionMessages.php @@ -35,4 +35,6 @@ final class ExceptionMessages extends BaseExceptionMessages public const MISSING_MODULE_DIRECTORY = 'Module directory does not exist, skipping config update.'; public const MODULE_ALREADY_EXISTS = 'A module or prefix named `{%1}` already exists'; + + public const DIRECTORY_LISTING_FAILED = 'Failed to list directory `{%1}`'; } diff --git a/src/Module/Exceptions/ModuleException.php b/src/Module/Exceptions/ModuleException.php index 694dc20d..4ec477d0 100644 --- a/src/Module/Exceptions/ModuleException.php +++ b/src/Module/Exceptions/ModuleException.php @@ -71,4 +71,12 @@ public static function moduleAlreadyExists(string $name): self E_ERROR ); } + + public static function directoryListingFailed(string $directory): self + { + return new self( + _message(ExceptionMessages::DIRECTORY_LISTING_FAILED, [$directory]), + E_ERROR + ); + } } diff --git a/src/Module/ModuleManager.php b/src/Module/ModuleManager.php index 2c002a8b..435c7da8 100644 --- a/src/Module/ModuleManager.php +++ b/src/Module/ModuleManager.php @@ -158,6 +158,10 @@ private function copyDirectory(string $src, string $dst, bool $processTemplates, $dir = $this->fs->listDirectory($src); + if (!is_array($dir)) { + throw ModuleException::directoryListingFailed($src); + } + foreach ($dir as $file) { $srcPath = $file; $dstPath = str_replace($src, $dst, $file); @@ -181,6 +185,11 @@ private function processTemplates(string $srcPath, string &$dstPath): void { $dstPath = str_replace('.php.tpl', '.php', $dstPath); $content = $this->fs->get($srcPath); + + if (!is_string($content)) { + return; + } + $processedContent = $this->replacePlaceholders($content); $this->fs->put($dstPath, $processedContent); } diff --git a/src/Paginator/Traits/PaginatorTrait.php b/src/Paginator/Traits/PaginatorTrait.php index f3264f74..00dd0c0b 100644 --- a/src/Paginator/Traits/PaginatorTrait.php +++ b/src/Paginator/Traits/PaginatorTrait.php @@ -220,7 +220,7 @@ protected function getUri(bool $withBaseUrl = false): string $url = $this->baseUrl . $routeUrl; } - $delimiter = strpos($url, '?') ? '&' : '?'; + $delimiter = strpos($url ?? '', '?') !== false ? '&' : '?'; return $url . $delimiter; } @@ -275,7 +275,7 @@ protected function getPreviousPageItem(?string $previousPageLink): string /** * Get items links HTML - * @param array $links + * @param array $links */ protected function getItemsLinks(int $startPage, int $endPage, int $currentPage, array $links): string { @@ -320,7 +320,7 @@ protected function addFirstPageLink(int $startPage): string /** * Add last page link HTML - * @param array $links + * @param array $links */ protected function addLastPageLink(int $endPage, int $totalPages, array $links): string { diff --git a/src/Renderer/Adapters/HtmlAdapter.php b/src/Renderer/Adapters/HtmlAdapter.php index eede5c46..1b1082c1 100644 --- a/src/Renderer/Adapters/HtmlAdapter.php +++ b/src/Renderer/Adapters/HtmlAdapter.php @@ -66,6 +66,7 @@ public function render(string $view, array $params = []): string } ob_start(); + /** @phpstan-ignore argument.type */ ob_implicit_flush(PHP_VERSION_ID >= 80000 ? false : 0); if ($params !== []) { @@ -74,7 +75,9 @@ public function render(string $view, array $params = []): string require $filePath; - return ob_get_clean(); + $content = ob_get_clean(); + + return $content !== false ? $content : ''; } /** diff --git a/src/Renderer/Adapters/TwigAdapter.php b/src/Renderer/Adapters/TwigAdapter.php index a792c4d4..4a5a52ba 100644 --- a/src/Renderer/Adapters/TwigAdapter.php +++ b/src/Renderer/Adapters/TwigAdapter.php @@ -67,7 +67,7 @@ public function render(string $view, array $params = []): string { $loader = $this->getLoader($view); - $twig = new Environment($loader, $this->configs); + $twig = new Environment($loader, $this->configs ?? []); $this->addFunctionsToTwig($twig); diff --git a/src/Renderer/Factories/RendererFactory.php b/src/Renderer/Factories/RendererFactory.php index 17a02bb4..4112f888 100644 --- a/src/Renderer/Factories/RendererFactory.php +++ b/src/Renderer/Factories/RendererFactory.php @@ -16,6 +16,7 @@ namespace Quantum\Renderer\Factories; +use Quantum\Renderer\Contracts\TemplateRendererInterface; use Quantum\Renderer\Exceptions\RendererException; use Quantum\Config\Exceptions\ConfigException; use Quantum\Renderer\Adapters\HtmlAdapter; @@ -69,9 +70,18 @@ public static function get(?string $adapter = null): Renderer return self::$instances[$adapter]; } + /** + * @throws RendererException + */ private static function createInstance(string $adapterClass, string $adapter): Renderer { - return new Renderer(new $adapterClass(config()->get('view.' . $adapter))); + $adapterInstance = new $adapterClass(config()->get('view.' . $adapter)); + + if (!$adapterInstance instanceof TemplateRendererInterface) { + throw RendererException::adapterNotSupported($adapter); + } + + return new Renderer($adapterInstance); } /** diff --git a/src/ResourceCache/ViewCache.php b/src/ResourceCache/ViewCache.php index 280dc14b..35fe7551 100644 --- a/src/ResourceCache/ViewCache.php +++ b/src/ResourceCache/ViewCache.php @@ -103,8 +103,11 @@ public function setup(): void public function serveCachedView(string $uri, Response $response): bool { if ($this->isEnabled() && $this->exists($uri)) { - $response->html($this->get($uri)); - return true; + $cachedContent = $this->get($uri); + if ($cachedContent !== null) { + $response->html($cachedContent); + return true; + } } return false; @@ -141,7 +144,9 @@ public function get(string $key): ?string return null; } - return $this->fs->get($this->getCacheFile($key)); + $content = $this->fs->get($this->getCacheFile($key)); + + return is_string($content) ? $content : null; } /** diff --git a/src/Router/Helpers/router.php b/src/Router/Helpers/router.php index 1cfebb4b..d2261c2a 100644 --- a/src/Router/Helpers/router.php +++ b/src/Router/Helpers/router.php @@ -21,7 +21,7 @@ /** * Gets current route middlewares - * @return array|null + * @return array|null * @throws DiException|ReflectionException */ function current_middlewares(): ?array @@ -107,7 +107,7 @@ function route_pattern(): string $request = Di::get(Request::class); $matchedRoute = $request->getMatchedRoute(); - return $matchedRoute ? $matchedRoute->getRoute()->getCompiledPattern() : ''; + return $matchedRoute ? ($matchedRoute->getRoute()->getCompiledPattern() ?? '') : ''; } /** @@ -143,7 +143,7 @@ function route_param(string $name) function route_method(): string { $request = Di::get(Request::class); - return $request->getMethod(); + return $request->getMethod() ?? ''; } /** diff --git a/src/Router/PatternCompiler.php b/src/Router/PatternCompiler.php index 5404429d..a7ff548b 100644 --- a/src/Router/PatternCompiler.php +++ b/src/Router/PatternCompiler.php @@ -49,7 +49,7 @@ public function match(Route $route, string $uri): bool { [$pattern, $segmentParams] = $this->compile($route); - $requestUri = urldecode(parse_url($uri, PHP_URL_PATH) ?? ''); + $requestUri = urldecode(parse_url($uri, PHP_URL_PATH) ?: ''); if (!preg_match('/^' . $this->escape($pattern) . '$/u', $requestUri, $matches)) { $this->params = []; @@ -115,7 +115,7 @@ public function getParams(): array * Build the final named parameter map from regex matches and param metadata. * * @param array $matches PCRE match array from preg_match (named captures) - * @param array $segmentParams + * @param list> $segmentParams * @return array */ protected function extractParams(array $matches, array $segmentParams): array @@ -156,7 +156,7 @@ protected function getSegmentParam(string $segment, int $index, int $lastIndex): /** * Generate the regex pattern and name for a matched parameter segment. * @param array $match - * @return array + * @return array{name: string, pattern: string} * @throws RouteException */ protected function getParamPattern(array $match, string $expr, int $index, int $lastIndex): array @@ -212,7 +212,7 @@ protected function getParamName(array $match, int $index): string * @param array|list> $params * @throws RouteException */ - protected function checkParamName($params, string $name): void + protected function checkParamName(array $params, string $name): void { foreach ($params as $param) { if ($param['name'] === $name) { diff --git a/src/Router/Route.php b/src/Router/Route.php index 7be833f1..feff8681 100644 --- a/src/Router/Route.php +++ b/src/Router/Route.php @@ -50,7 +50,7 @@ final class Route protected ?string $module = null; /** - * @var array|null + * @var array|null */ protected ?array $middlewares = null; @@ -187,7 +187,7 @@ public function getGroup(): ?string */ public function prefix(?string $prefix): self { - $this->prefix = $prefix !== '' ? trim($prefix, '/') : null; + $this->prefix = ($prefix !== null && $prefix !== '') ? trim($prefix, '/') : null; return $this; } @@ -300,7 +300,7 @@ public function getName(): ?string /** * Return middleware list. - * @return array|null + * @return array|null */ public function getMiddlewares(): ?array { diff --git a/src/Router/RouteBuilder.php b/src/Router/RouteBuilder.php index 5f618912..464ae1ee 100644 --- a/src/Router/RouteBuilder.php +++ b/src/Router/RouteBuilder.php @@ -41,7 +41,7 @@ final class RouteBuilder private ?string $currentGroupName = null; /** - * @var array + * @var array */ private array $groupRoutes = []; diff --git a/src/Router/RouteDispatcher.php b/src/Router/RouteDispatcher.php index 0146a7f5..45038e54 100644 --- a/src/Router/RouteDispatcher.php +++ b/src/Router/RouteDispatcher.php @@ -57,6 +57,7 @@ public function dispatch(MatchedRoute $matched, Request $request): void $this->callHook($controller, '__before', $params); + /** @phpstan-ignore argument.type */ $this->invoke($callable, $params); $this->callHook($controller, '__after', $params); @@ -106,6 +107,7 @@ private function invoke(callable $callable, array $params): void private function callHook(object $controller, string $hook, array $params): void { if (method_exists($controller, $hook)) { + /** @phpstan-ignore argument.type */ $this->invoke([$controller, $hook], $params); } } diff --git a/src/Router/RouteFinder.php b/src/Router/RouteFinder.php index 8bab1ef6..b7482ca3 100644 --- a/src/Router/RouteFinder.php +++ b/src/Router/RouteFinder.php @@ -41,8 +41,8 @@ public function __construct(RouteCollection $routes) */ public function find(Request $request): ?MatchedRoute { - $method = $request->getMethod(); - $uri = $request->getUri(); + $method = $request->getMethod() ?? ''; + $uri = $request->getUri() ?? ''; foreach ($this->routes->all() as $route) { if (!$route->allowsMethod($method)) { diff --git a/src/Session/Factories/SessionFactory.php b/src/Session/Factories/SessionFactory.php index 39280f2e..8a104cf8 100644 --- a/src/Session/Factories/SessionFactory.php +++ b/src/Session/Factories/SessionFactory.php @@ -18,6 +18,7 @@ use Quantum\Session\Adapters\Database\DatabaseSessionAdapter; use Quantum\Session\Adapters\Native\NativeSessionAdapter; +use Quantum\Session\Contracts\SessionStorageInterface; use Quantum\Session\Exceptions\SessionException; use Quantum\Config\Exceptions\ConfigException; use Quantum\App\Exceptions\BaseException; @@ -71,7 +72,13 @@ public static function get(?string $adapter = null): Session private static function createInstance(string $adapterClass, string $adapter): Session { - return new Session(new $adapterClass(config()->get('session.' . $adapter))); + $adapterInstance = new $adapterClass(config()->get('session.' . $adapter)); + + if (!$adapterInstance instanceof SessionStorageInterface) { + throw SessionException::adapterNotSupported($adapter); + } + + return new Session($adapterInstance); } /** diff --git a/src/Session/Traits/SessionTrait.php b/src/Session/Traits/SessionTrait.php index 3fa3894c..789e7933 100644 --- a/src/Session/Traits/SessionTrait.php +++ b/src/Session/Traits/SessionTrait.php @@ -113,7 +113,9 @@ public function flush(): void */ public function getId(): ?string { - return session_id(); + $id = session_id(); + + return $id !== false ? $id : null; } /** diff --git a/src/Storage/Adapters/Dropbox/DropboxApp.php b/src/Storage/Adapters/Dropbox/DropboxApp.php index baecdea2..64a6f81a 100644 --- a/src/Storage/Adapters/Dropbox/DropboxApp.php +++ b/src/Storage/Adapters/Dropbox/DropboxApp.php @@ -219,7 +219,7 @@ public function contentRequest(string $endpoint, array $params, string $content { $headers = [ 'Authorization' => 'Bearer ' . $this->tokenService->getAccessToken(), - 'Dropbox-API-Arg' => json_encode($params), + 'Dropbox-API-Arg' => json_encode($params) ?: '', 'Content-Type' => 'application/octet-stream', ]; diff --git a/src/Storage/Adapters/Local/LocalFileSystemAdapter.php b/src/Storage/Adapters/Local/LocalFileSystemAdapter.php index b0ed88f1..15fb72bb 100644 --- a/src/Storage/Adapters/Local/LocalFileSystemAdapter.php +++ b/src/Storage/Adapters/Local/LocalFileSystemAdapter.php @@ -160,7 +160,11 @@ public function listDirectory(string $dirname) try { foreach (scandir($dirname) as $item) { if ($item !== '.' && $item !== '..') { - $entries[] = realpath($dirname . DS . $item); + $resolved = realpath($dirname . DS . $item); + + if ($resolved !== false) { + $entries[] = $resolved; + } } } diff --git a/src/Storage/Factories/FileSystemFactory.php b/src/Storage/Factories/FileSystemFactory.php index 270a77f7..aff8bcbc 100644 --- a/src/Storage/Factories/FileSystemFactory.php +++ b/src/Storage/Factories/FileSystemFactory.php @@ -19,6 +19,7 @@ use Quantum\Storage\Adapters\GoogleDrive\GoogleDriveFileSystemAdapter; use Quantum\Storage\Adapters\Dropbox\DropboxFileSystemAdapter; use Quantum\Storage\Adapters\Local\LocalFileSystemAdapter; +use Quantum\Storage\Contracts\FilesystemAdapterInterface; use Quantum\Storage\Adapters\GoogleDrive\GoogleDriveApp; use Quantum\Storage\Contracts\TokenServiceInterface; use Quantum\Storage\Exceptions\FileSystemException; @@ -59,7 +60,7 @@ class FileSystemFactory ]; /** - * @var array + * @var array */ private static array $instances = []; @@ -94,9 +95,13 @@ public static function get(?string $adapter = null): FileSystem */ private static function createInstance(string $adapterClass, string $adapter): FileSystem { - return new FileSystem(new $adapterClass( - self::createCloudApp($adapter) - )); + $fsAdapter = new $adapterClass(self::createCloudApp($adapter)); + + if (!$fsAdapter instanceof FilesystemAdapterInterface) { + throw FileSystemException::adapterNotSupported($adapter); + } + + return new FileSystem($fsAdapter); } /** diff --git a/src/Storage/Traits/CloudAppTrait.php b/src/Storage/Traits/CloudAppTrait.php index 6eb77890..33635f6a 100644 --- a/src/Storage/Traits/CloudAppTrait.php +++ b/src/Storage/Traits/CloudAppTrait.php @@ -51,6 +51,11 @@ public function sendRequest(string $uri, $data = null, array $headers = [], stri if ($this->accessTokenNeedsRefresh($code, $responseBody)) { $prevUrl = $this->httpClient->url(); + + if (empty($prevUrl)) { + throw new Exception('Cannot retry request: original URL is not available.', E_ERROR); + } + $prevData = $this->httpClient->getData(); $prevHeaders = $this->httpClient->getRequestHeaders(); @@ -63,7 +68,10 @@ public function sendRequest(string $uri, $data = null, array $headers = [], stri $responseBody = $this->sendRequest($prevUrl, $prevData, $prevHeaders); } else { - throw new Exception(json_encode($responseBody ?? $errors), E_ERROR); + throw new Exception( + json_encode($responseBody ?? $errors) ?: '', + E_ERROR + ); } } diff --git a/src/Storage/UploadedFile.php b/src/Storage/UploadedFile.php index 86e8402b..b8313c6a 100644 --- a/src/Storage/UploadedFile.php +++ b/src/Storage/UploadedFile.php @@ -30,6 +30,7 @@ use Quantum\Loader\Setup; use ReflectionException; use Gumlet\ImageResize; +use RuntimeException; use Quantum\Di\Di; use SplFileInfo; use finfo; @@ -156,7 +157,7 @@ public function setAllowedMimeTypes(array $allowedMimeTypes, bool $merge = true) public function getName(): string { if (!$this->name) { - $this->name = $this->localFileSystem->fileName($this->originalName); + $this->name = $this->localFileSystem->fileName($this->originalName ?? ''); } return $this->name; @@ -194,7 +195,7 @@ public function getRemoteFileSystem(): ?FilesystemAdapterInterface public function getExtension(): string { if (!$this->extension) { - $this->extension = strtolower($this->localFileSystem->extension($this->originalName)); + $this->extension = strtolower($this->localFileSystem->extension($this->originalName ?? '')); } return $this->extension; @@ -216,7 +217,17 @@ public function getMimeType(): string if (!$this->mimetype) { $fileInfo = new finfo(FILEINFO_MIME); $mimetype = $fileInfo->file($this->getPathname()); + + if (!is_string($mimetype)) { + throw new RuntimeException('Failed to determine MIME type for: ' . $this->getPathname()); + } + $mimetypeParts = preg_split('/\s*[;,]\s*/', $mimetype); + + if (!is_array($mimetypeParts) || empty($mimetypeParts[0])) { + throw new RuntimeException('Failed to parse MIME type: ' . $mimetype); + } + $this->mimetype = strtolower($mimetypeParts[0]); unset($fileInfo); } @@ -229,7 +240,7 @@ public function getMimeType(): string */ public function getMd5(): string { - return md5_file($this->getPathname()); + return md5_file($this->getPathname()) ?: ''; } /** @@ -243,11 +254,15 @@ public function getDimensions(): array throw FileUploadException::fileTypeNotAllowed($this->getExtension()); } - [$width, $height] = getimagesize($this->getPathname()); + $size = getimagesize($this->getPathname()); + + if ($size === false) { + throw FileUploadException::fileTypeNotAllowed($this->getExtension()); + } return [ - 'width' => $width, - 'height' => $height, + 'width' => $size[0], + 'height' => $size[1], ]; } @@ -432,7 +447,13 @@ protected function setAllowedMimeTypesMap(array $allowedMimeTypes, bool $merge = protected function applyModifications(string $filePath) { $image = new ImageResize($filePath); - call_user_func_array([$image, $this->imageModifierFuncName], array_values($this->params)); + $callable = [$image, $this->imageModifierFuncName ?? '']; + + if (!is_callable($callable)) { + throw new RuntimeException('Invalid image modifier: ' . ($this->imageModifierFuncName ?? 'null')); + } + + call_user_func_array($callable, array_values($this->params)); $image->save($filePath); } diff --git a/src/Tracer/ErrorHandler.php b/src/Tracer/ErrorHandler.php index a5e9b506..0e29de98 100644 --- a/src/Tracer/ErrorHandler.php +++ b/src/Tracer/ErrorHandler.php @@ -157,6 +157,10 @@ private function handleWebException(Throwable $throwable): void private function logError(Throwable $e, string $errorType): void { + if ($this->logger === null) { + return; + } + $logMethod = method_exists($this->logger, $errorType) ? $errorType : 'error'; $this->logger->$logMethod($e->getMessage(), ['trace' => $e->getTraceAsString()]); @@ -204,7 +208,8 @@ private function getSourceCode(string $filename, int $lineNumber, string $classN { $lineNumber--; - $start = max($lineNumber - floor(self::NUM_LINES / 2), 1); + $halfLines = intdiv(self::NUM_LINES, 2); + $start = max($lineNumber - $halfLines, 1); $adapter = fs()->getAdapter(); diff --git a/src/Validation/Traits/General.php b/src/Validation/Traits/General.php index d7eec2dd..41d59e9c 100644 --- a/src/Validation/Traits/General.php +++ b/src/Validation/Traits/General.php @@ -57,7 +57,7 @@ protected function email($value): bool protected function creditCard($value): bool { $value = (string) $value; - $number = preg_replace('/\D/', '', $value); + $number = preg_replace('/\D/', '', $value) ?? ''; $length = function_exists('mb_strlen') ? mb_strlen($number) : strlen($number); if ($length == 0) { @@ -153,8 +153,14 @@ protected function date($value, ?string $format = null): bool { $value = (string) $value; if (!$format) { - $cdate1 = date('Y-m-d', strtotime($value)); - $cdate2 = date('Y-m-d H:i:s', strtotime($value)); + $timestamp = strtotime($value); + + if ($timestamp === false) { + return false; + } + + $cdate1 = date('Y-m-d', $timestamp); + $cdate2 = date('Y-m-d H:i:s', $timestamp); return $cdate1 === $value || $cdate2 === $value; } @@ -211,11 +217,11 @@ protected function same($value, string $otherField): bool protected function unique($value, string $className, string $columnName): bool { /** @var DbModel $model */ - $model = ModelFactory::get(ucfirst($className)); + $model = ModelFactory::get(ucfirst($className)); /** @phpstan-ignore argument.type, argument.templateType */ $record = $model->findOneBy($columnName, $value); - return $record->isEmpty(); + return $record === null || $record->isEmpty(); } /** @@ -227,11 +233,11 @@ protected function unique($value, string $className, string $columnName): bool protected function exists($value, string $className, string $columnName): bool { /** @var DbModel $model */ - $model = ModelFactory::get(ucfirst($className)); + $model = ModelFactory::get(ucfirst($className)); /** @phpstan-ignore argument.type, argument.templateType */ $record = $model->findOneBy($columnName, $value); - return !$record->isEmpty(); + return $record !== null && !$record->isEmpty(); } /** diff --git a/src/Validation/Traits/Resource.php b/src/Validation/Traits/Resource.php index 05caae17..19f50017 100644 --- a/src/Validation/Traits/Resource.php +++ b/src/Validation/Traits/Resource.php @@ -46,7 +46,8 @@ protected function urlExists(string $value): bool } if (function_exists('checkdnsrr') && function_exists('idn_to_ascii')) { - return checkdnsrr(idn_to_ascii($host, 0, INTL_IDNA_VARIANT_UTS46), 'A'); + $ascii = idn_to_ascii($host, 0, INTL_IDNA_VARIANT_UTS46); + return $ascii !== false && checkdnsrr($ascii, 'A'); } else { return gethostbyname($host) !== $host; } diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index a458405e..94df7b62 100644 --- a/src/Validation/Validator.php +++ b/src/Validation/Validator.php @@ -74,7 +74,7 @@ class Validator public function setRule(string $field, array $rules): void { foreach ($rules as $rule) { - $ruleName = key($rule); + $ruleName = (string) key($rule); $ruleParam = current($rule); $this->setOrUpdateRule($field, $ruleName, $ruleParam); } @@ -97,7 +97,7 @@ public function setRules(array $rules): void */ public function updateRule(string $field, array $rule): void { - $ruleName = key($rule); + $ruleName = (string) key($rule); $ruleParam = current($rule); $this->setOrUpdateRule($field, $ruleName, $ruleParam); } diff --git a/src/View/QtView.php b/src/View/QtView.php index c787cb13..03062677 100644 --- a/src/View/QtView.php +++ b/src/View/QtView.php @@ -173,7 +173,9 @@ public function flushParams(): void */ public function render(string $viewFile, array $params = []): ?string { - if (!$this->layoutFile) { + $layoutFile = $this->layoutFile; + + if (!$layoutFile) { throw ViewException::noLayoutSet(); } @@ -191,12 +193,15 @@ public function render(string $viewFile, array $params = []): ?string $this->updateDebugger($viewFile); } - $layoutContent = $this->renderFile($this->layoutFile); + $layoutContent = $this->renderFile($layoutFile); if ($this->viewCache->isEnabled()) { - $layoutContent = $this->viewCache - ->set(route_uri(), $layoutContent) - ->get(route_uri()); + $uri = route_uri(); + if ($uri !== null) { + $layoutContent = $this->viewCache + ->set($uri, $layoutContent) + ->get($uri); + } } return $layoutContent; @@ -277,6 +282,10 @@ private function sanitizeHtml(string $value): string return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } + /** + * @throws ReflectionException + * @throws DiException + */ private function updateDebugger(string $viewFile): void { $routesCell = $this->debugger->getStoreCell(Debugger::ROUTES); diff --git a/tests/Unit/Cache/Adapters/DatabaseAdapterTest.php b/tests/Unit/Cache/Adapters/DatabaseAdapterTest.php index 473425a8..8b1bf132 100644 --- a/tests/Unit/Cache/Adapters/DatabaseAdapterTest.php +++ b/tests/Unit/Cache/Adapters/DatabaseAdapterTest.php @@ -6,6 +6,7 @@ use Quantum\Cache\Adapters\DatabaseAdapter; use Quantum\Tests\Unit\AppTestCase; use Quantum\Loader\Setup; +use DateInterval; class DatabaseAdapterTest extends AppTestCase { @@ -161,4 +162,42 @@ public function testDatabaseAdapterExpired(): void $this->assertNull($databaseCache->get('test')); } + public function testDatabaseAdapterSetWithCustomTtl(): void + { + $this->databaseCache->set('test', 'Test value', 120); + + $this->assertTrue($this->databaseCache->has('test')); + + $this->assertEquals('Test value', $this->databaseCache->get('test')); + } + + public function testDatabaseAdapterSetWithDateIntervalTtl(): void + { + $this->databaseCache->set('test', 'Test value', new DateInterval('PT60S')); + + $this->assertTrue($this->databaseCache->has('test')); + + $this->assertEquals('Test value', $this->databaseCache->get('test')); + } + + public function testDatabaseAdapterExpiredWithCustomTtl(): void + { + $this->databaseCache->set('test', 'Test value', -1); + + $this->assertNull($this->databaseCache->get('test')); + } + + public function testDatabaseAdapterPerKeyTtlIndependence(): void + { + $this->databaseCache->set('short', 'Short TTL', -1); + + $this->databaseCache->set('long', 'Long TTL', 120); + + $this->assertFalse($this->databaseCache->has('short')); + + $this->assertTrue($this->databaseCache->has('long')); + + $this->assertEquals('Long TTL', $this->databaseCache->get('long')); + } + } diff --git a/tests/Unit/Cache/Adapters/FileAdapterTest.php b/tests/Unit/Cache/Adapters/FileAdapterTest.php index 04bb355d..5c3ae43b 100644 --- a/tests/Unit/Cache/Adapters/FileAdapterTest.php +++ b/tests/Unit/Cache/Adapters/FileAdapterTest.php @@ -4,6 +4,7 @@ use Quantum\Cache\Adapters\FileAdapter; use Quantum\Tests\Unit\AppTestCase; +use DateInterval; class FileAdapterTest extends AppTestCase { @@ -138,4 +139,42 @@ public function testFileAdapterExpired(): void $this->assertNull($fileCache->get('test')); } + public function testFileAdapterSetWithCustomTtl(): void + { + $this->fileCache->set('test', 'Test value', 120); + + $this->assertTrue($this->fileCache->has('test')); + + $this->assertEquals('Test value', $this->fileCache->get('test')); + } + + public function testFileAdapterSetWithDateIntervalTtl(): void + { + $this->fileCache->set('test', 'Test value', new DateInterval('PT60S')); + + $this->assertTrue($this->fileCache->has('test')); + + $this->assertEquals('Test value', $this->fileCache->get('test')); + } + + public function testFileAdapterExpiredWithCustomTtl(): void + { + $this->fileCache->set('test', 'Test value', -1); + + $this->assertNull($this->fileCache->get('test')); + } + + public function testFileAdapterPerKeyTtlIndependence(): void + { + $this->fileCache->set('short', 'Short TTL', -1); + + $this->fileCache->set('long', 'Long TTL', 120); + + $this->assertFalse($this->fileCache->has('short')); + + $this->assertTrue($this->fileCache->has('long')); + + $this->assertEquals('Long TTL', $this->fileCache->get('long')); + } + } diff --git a/tests/Unit/Cache/Adapters/MemcachedAdapterTest.php b/tests/Unit/Cache/Adapters/MemcachedAdapterTest.php index 5d17901d..fd63e842 100644 --- a/tests/Unit/Cache/Adapters/MemcachedAdapterTest.php +++ b/tests/Unit/Cache/Adapters/MemcachedAdapterTest.php @@ -4,6 +4,7 @@ use Quantum\Cache\Adapters\MemcachedAdapter; use Quantum\Tests\Unit\AppTestCase; +use DateInterval; class MemcachedAdapterTest extends AppTestCase { @@ -153,4 +154,31 @@ public function testMemcachedAdapterExpired(): void $this->assertNull($memCached->get('test')); } + public function testMemcachedAdapterSetWithCustomTtl(): void + { + $this->memCached->set('test', 'Test value', 120); + + $this->assertTrue($this->memCached->has('test')); + + $this->assertEquals('Test value', $this->memCached->get('test')); + } + + public function testMemcachedAdapterSetWithDateIntervalTtl(): void + { + $this->memCached->set('test', 'Test value', new DateInterval('PT60S')); + + $this->assertTrue($this->memCached->has('test')); + + $this->assertEquals('Test value', $this->memCached->get('test')); + } + + public function testMemcachedAdapterExpiredWithCustomTtl(): void + { + $this->memCached->set('test', 'Test value', 1); + + sleep(2); + + $this->assertNull($this->memCached->get('test')); + } + } diff --git a/tests/Unit/Cache/Adapters/RedisAdapterTest.php b/tests/Unit/Cache/Adapters/RedisAdapterTest.php index 79c69977..f944ab62 100644 --- a/tests/Unit/Cache/Adapters/RedisAdapterTest.php +++ b/tests/Unit/Cache/Adapters/RedisAdapterTest.php @@ -4,6 +4,7 @@ use Quantum\Cache\Adapters\RedisAdapter; use Quantum\Tests\Unit\AppTestCase; +use DateInterval; class RedisAdapterTest extends AppTestCase { @@ -153,4 +154,31 @@ public function testRedisAdapterExpired(): void $this->assertNull($redis->get('test')); } + public function testRedisAdapterSetWithCustomTtl(): void + { + $this->redis->set('test', 'Test value', 120); + + $this->assertTrue($this->redis->has('test')); + + $this->assertEquals('Test value', $this->redis->get('test')); + } + + public function testRedisAdapterSetWithDateIntervalTtl(): void + { + $this->redis->set('test', 'Test value', new DateInterval('PT60S')); + + $this->assertTrue($this->redis->has('test')); + + $this->assertEquals('Test value', $this->redis->get('test')); + } + + public function testRedisAdapterExpiredWithCustomTtl(): void + { + $this->redis->set('test', 'Test value', 1); + + sleep(2); + + $this->assertNull($this->redis->get('test')); + } + } diff --git a/tests/Unit/Console/Commands/DebugBarCommandTest.php b/tests/Unit/Console/Commands/DebugBarCommandTest.php new file mode 100644 index 00000000..44f7a28e --- /dev/null +++ b/tests/Unit/Console/Commands/DebugBarCommandTest.php @@ -0,0 +1,53 @@ +command = new DebugBarCommand(); + $this->tester = new CommandTester($this->command); + } + + public function testCommandMetadata(): void + { + $this->assertSame('install:debugbar', $this->command->getName()); + $this->assertSame('Publishes debugbar assets', $this->command->getDescription()); + $this->assertSame('The command will publish debugbar assets', $this->command->getHelp()); + } + + public function testConstructorInitializesFileSystem(): void + { + $fs = $this->getPrivateProperty($this->command, 'fs'); + $this->assertInstanceOf(FileSystem::class, $fs); + } + + public function testExecShowsErrorWhenAlreadyInstalled(): void + { + $assetsPath = assets_dir() . DS . 'DebugBar' . DS . 'Resources'; + + mkdir($assetsPath, 0777, true); + file_put_contents($assetsPath . DS . 'debugbar.css', '/* stub */'); + + $this->tester->execute([]); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('already installed', $output); + + @unlink($assetsPath . DS . 'debugbar.css'); + @rmdir($assetsPath); + @rmdir(assets_dir() . DS . 'DebugBar'); + } +} diff --git a/tests/Unit/Console/Commands/EnvCommandTest.php b/tests/Unit/Console/Commands/EnvCommandTest.php new file mode 100644 index 00000000..6fc9979b --- /dev/null +++ b/tests/Unit/Console/Commands/EnvCommandTest.php @@ -0,0 +1,51 @@ +command = new EnvCommand(); + $this->tester = new CommandTester($this->command); + } + + public function testCommandMetadata(): void + { + $this->assertSame('core:env', $this->command->getName()); + $this->assertSame('Generates new .env file', $this->command->getDescription()); + $this->assertSame('The command will generate new .env file from .env.example', $this->command->getHelp()); + } + + public function testExecShowsErrorWhenEnvExampleMissing(): void + { + $envExample = base_dir() . DS . '.env.example'; + $backup = $envExample . '.' . uniqid('bak', true); + $existed = file_exists($envExample); + + if ($existed) { + rename($envExample, $backup); + } + + try { + $this->tester->execute([]); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('.env.example file not found', $output); + } finally { + if ($existed && file_exists($backup)) { + rename($backup, $envExample); + } + } + } +} diff --git a/tests/Unit/Console/Commands/InstallToolkitCommandTest.php b/tests/Unit/Console/Commands/InstallToolkitCommandTest.php new file mode 100644 index 00000000..6f94c754 --- /dev/null +++ b/tests/Unit/Console/Commands/InstallToolkitCommandTest.php @@ -0,0 +1,35 @@ +command = new InstallToolkitCommand(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('install:toolkit', $this->command->getName()); + $this->assertSame('Installs toolkit', $this->command->getDescription()); + $this->assertSame('The command will install Toolkit and its assets into your project', $this->command->getHelp()); + } + + public function testCommandArgumentsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasArgument('username')); + $this->assertTrue($definition->hasArgument('password')); + $this->assertTrue($definition->getArgument('username')->isRequired()); + $this->assertTrue($definition->getArgument('password')->isRequired()); + } +} diff --git a/tests/Unit/Console/Commands/KeyGenerateCommandTest.php b/tests/Unit/Console/Commands/KeyGenerateCommandTest.php new file mode 100644 index 00000000..ca9c58cd --- /dev/null +++ b/tests/Unit/Console/Commands/KeyGenerateCommandTest.php @@ -0,0 +1,81 @@ + */ + private array $originalEnvData; + + public function setUp(): void + { + parent::setUp(); + + $this->envFilePath = App::getBaseDir() . DS . '.env.testing'; + $this->originalFileContent = (string) $this->fs->get($this->envFilePath); + $this->originalEnvData = $this->getPrivateProperty(Environment::getInstance(), 'envContent'); + + $this->command = new KeyGenerateCommand(); + $this->command->setHelperSet(new HelperSet(['question' => new QuestionHelper()])); + $this->tester = new CommandTester($this->command); + } + + public function tearDown(): void + { + $this->fs->put($this->envFilePath, $this->originalFileContent); + $this->setPrivateProperty(Environment::getInstance(), 'envContent', $this->originalEnvData); + + parent::tearDown(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('core:key', $this->command->getName()); + $this->assertSame('Generates and stores the application key', $this->command->getDescription()); + $this->assertSame('The command will generate APP_KEY and store in .env file', $this->command->getHelp()); + } + + public function testCommandOptionsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasOption('length')); + $this->assertTrue($definition->hasOption('yes')); + } + + public function testExecGeneratesKey(): void + { + $this->tester->execute(['--yes' => true, '--length' => 16]); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('Application key successfully generated', $output); + $this->assertNotEmpty(env('APP_KEY')); + } + + public function testExecCancelsWhenNotConfirmed(): void + { + env('APP_KEY', 'existing-key'); + + $this->tester->setInputs(['n']); + $this->tester->execute([]); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('Operation was canceled', $output); + } +} diff --git a/tests/Unit/Console/Commands/MigrationGenerateCommandTest.php b/tests/Unit/Console/Commands/MigrationGenerateCommandTest.php new file mode 100644 index 00000000..09166e0d --- /dev/null +++ b/tests/Unit/Console/Commands/MigrationGenerateCommandTest.php @@ -0,0 +1,34 @@ +command = new MigrationGenerateCommand(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('migration:generate', $this->command->getName()); + $this->assertSame('Generates new migration file', $this->command->getDescription()); + } + + public function testCommandArgumentsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasArgument('action')); + $this->assertTrue($definition->hasArgument('table')); + $this->assertTrue($definition->getArgument('action')->isRequired()); + $this->assertTrue($definition->getArgument('table')->isRequired()); + } +} diff --git a/tests/Unit/Console/Commands/MigrationMigrateCommandTest.php b/tests/Unit/Console/Commands/MigrationMigrateCommandTest.php new file mode 100644 index 00000000..bf943bf0 --- /dev/null +++ b/tests/Unit/Console/Commands/MigrationMigrateCommandTest.php @@ -0,0 +1,34 @@ +command = new MigrationMigrateCommand(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('migration:migrate', $this->command->getName()); + $this->assertSame('Migrates the migrations', $this->command->getDescription()); + } + + public function testCommandArgumentsAndOptionsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasArgument('direction')); + $this->assertFalse($definition->getArgument('direction')->isRequired()); + + $this->assertTrue($definition->hasOption('step')); + } +} diff --git a/tests/Unit/Console/Commands/ModuleGenerateCommandTest.php b/tests/Unit/Console/Commands/ModuleGenerateCommandTest.php new file mode 100644 index 00000000..0aace025 --- /dev/null +++ b/tests/Unit/Console/Commands/ModuleGenerateCommandTest.php @@ -0,0 +1,37 @@ +command = new ModuleGenerateCommand(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('module:generate', $this->command->getName()); + $this->assertSame('Generate new module', $this->command->getDescription()); + $this->assertSame('The command will create files for new module', $this->command->getHelp()); + } + + public function testCommandArgumentsAndOptionsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasArgument('module')); + $this->assertTrue($definition->getArgument('module')->isRequired()); + + $this->assertTrue($definition->hasOption('yes')); + $this->assertTrue($definition->hasOption('template')); + $this->assertTrue($definition->hasOption('with-assets')); + } +} diff --git a/tests/Unit/Console/Commands/OpenApiCommandTest.php b/tests/Unit/Console/Commands/OpenApiCommandTest.php new file mode 100644 index 00000000..a3327166 --- /dev/null +++ b/tests/Unit/Console/Commands/OpenApiCommandTest.php @@ -0,0 +1,40 @@ +command = new OpenApiCommand(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('install:openapi', $this->command->getName()); + $this->assertSame('Generates files for OpenApi UI', $this->command->getDescription()); + $this->assertSame('The command will publish OpenApi UI resources', $this->command->getHelp()); + } + + public function testCommandArgumentsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasArgument('module')); + $this->assertTrue($definition->getArgument('module')->isRequired()); + } + + public function testConstructorInitializesFileSystem(): void + { + $fs = $this->getPrivateProperty($this->command, 'fs'); + $this->assertInstanceOf(FileSystem::class, $fs); + } +} diff --git a/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php b/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php new file mode 100644 index 00000000..1f672a2d --- /dev/null +++ b/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php @@ -0,0 +1,62 @@ +command = new ResourceCacheClearCommand(); + $this->tester = new CommandTester($this->command); + } + + public function testCommandMetadata(): void + { + $this->assertSame('cache:clear', $this->command->getName()); + $this->assertSame('Clears resource cache', $this->command->getDescription()); + $this->assertSame('The command will clear the resource cache', $this->command->getHelp()); + } + + public function testCommandOptionsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasOption('all')); + $this->assertTrue($definition->hasOption('type')); + $this->assertTrue($definition->hasOption('module')); + } + + public function testConstructorInitializesFileSystem(): void + { + $fs = $this->getPrivateProperty($this->command, 'fs'); + $this->assertInstanceOf(FileSystem::class, $fs); + } + + public function testExecShowsErrorWhenNoOptionsProvided(): void + { + config()->set('view_cache', ['cache_dir' => 'cache']); + + $cacheDir = base_dir() . DS . 'cache'; + if (!is_dir($cacheDir)) { + mkdir($cacheDir, 0777, true); + } + + $this->tester->execute([]); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('Please specify at least one of the following options', $output); + + @rmdir($cacheDir); + } +} diff --git a/tests/Unit/Console/Commands/RouteListCommandTest.php b/tests/Unit/Console/Commands/RouteListCommandTest.php new file mode 100644 index 00000000..58102ea4 --- /dev/null +++ b/tests/Unit/Console/Commands/RouteListCommandTest.php @@ -0,0 +1,32 @@ +command = new RouteListCommand(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('route:list', $this->command->getName()); + $this->assertSame('Display all registered routes', $this->command->getDescription()); + } + + public function testCommandOptionsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasOption('module')); + $this->assertFalse($definition->getOption('module')->isValueRequired()); + } +} diff --git a/tests/Unit/Console/Commands/ServeCommandTest.php b/tests/Unit/Console/Commands/ServeCommandTest.php new file mode 100644 index 00000000..7b7079a5 --- /dev/null +++ b/tests/Unit/Console/Commands/ServeCommandTest.php @@ -0,0 +1,49 @@ +command = new ServeCommand(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('serve', $this->command->getName()); + $this->assertSame('Serves the application on the PHP development server', $this->command->getDescription()); + } + + public function testCommandOptionsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasOption('host')); + $this->assertTrue($definition->hasOption('port')); + $this->assertTrue($definition->hasOption('open')); + } + + public function testBrowserCommandReturnsArrayForKnownPlatform(): void + { + $method = new \ReflectionMethod($this->command, 'browserCommand'); + $method->setAccessible(true); + + $result = $method->invoke($this->command, 'http://localhost:8000'); + + if (in_array(PHP_OS_FAMILY, ['Windows', 'Linux', 'Darwin'], true)) { + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertSame('http://localhost:8000', $result[1]); + } else { + $this->assertNull($result); + } + } +} diff --git a/tests/Unit/Console/Commands/VersionCommandTest.php b/tests/Unit/Console/Commands/VersionCommandTest.php new file mode 100644 index 00000000..e533d463 --- /dev/null +++ b/tests/Unit/Console/Commands/VersionCommandTest.php @@ -0,0 +1,25 @@ +command = new VersionCommand(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('core:version', $this->command->getName()); + $this->assertSame('Core version', $this->command->getDescription()); + $this->assertSame('Printing the current version of the framework into the terminal', $this->command->getHelp()); + } +} diff --git a/tests/Unit/Console/QtCommandTest.php b/tests/Unit/Console/QtCommandTest.php index c1c918dc..ebe89336 100644 --- a/tests/Unit/Console/QtCommandTest.php +++ b/tests/Unit/Console/QtCommandTest.php @@ -7,6 +7,7 @@ use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Helper\HelperSet; use Quantum\Tests\Unit\AppTestCase; +use RuntimeException; class QtCommandTest extends AppTestCase { @@ -97,4 +98,24 @@ public function testConfirmReturnsTrue(): void $this->assertTrue($this->command->confirm('Confirm action')); } + + public function testResolveInputThrowsWhenNotSet(): void + { + $command = new TestCommand(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Input is not set.'); + + $command->getArgument('uuid'); + } + + public function testResolveOutputThrowsWhenNotSet(): void + { + $command = new TestCommand(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Output is not set.'); + + $command->output('test'); + } } diff --git a/tests/Unit/Mailer/MailerTest.php b/tests/Unit/Mailer/MailerTest.php index 98fa204f..97260347 100644 --- a/tests/Unit/Mailer/MailerTest.php +++ b/tests/Unit/Mailer/MailerTest.php @@ -2,12 +2,18 @@ namespace Quantum\Tests\Unit\Mailer; +use Quantum\Mailer\Adapters\SendinblueAdapter; +use Quantum\Mailer\Adapters\MandrillAdapter; +use Quantum\Mailer\Adapters\SendgridAdapter; +use Quantum\Mailer\Adapters\MailgunAdapter; +use Quantum\Mailer\Adapters\ResendAdapter; use Quantum\Mailer\Exceptions\MailerException; use Quantum\Mailer\Contracts\MailerInterface; use Quantum\Mailer\Adapters\SmtpAdapter; use Quantum\Tests\Unit\AppTestCase; use Quantum\Mailer\Mailer; use Quantum\Loader\Setup; +use ReflectionMethod; class MailerTest extends AppTestCase { @@ -48,4 +54,37 @@ public function testMailerCallingInvalidMethod(): void $mailer->callingInvalidMethod(); } + + /** + * @dataProvider httpAdapterProvider + */ + public function testHttpAdapterGetTransportErrorsReturnsArray(string $adapterClass, array $params): void + { + $adapter = new $adapterClass($params); + + $httpClient = $this->getPrivateProperty($adapter, 'httpClient'); + $httpClient->createRequest('http://localhost'); + + $method = new ReflectionMethod($adapter, 'getTransportErrors'); + $method->setAccessible(true); + + $errors = $method->invoke($adapter); + + $this->assertIsArray($errors); + $this->assertEmpty($errors); + } + + /** + * @return array}> + */ + public function httpAdapterProvider(): array + { + return [ + 'mailgun' => [MailgunAdapter::class, ['api_key' => 'test', 'domain' => 'test.com']], + 'mandrill' => [MandrillAdapter::class, ['api_key' => 'test']], + 'resend' => [ResendAdapter::class, ['api_key' => 'test']], + 'sendgrid' => [SendgridAdapter::class, ['api_key' => 'test']], + 'sendinblue' => [SendinblueAdapter::class, ['api_key' => 'test']], + ]; + } }