From 9bd442e107c911e8045eb30dd3d2ad9a74c8dff5 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A." Date: Thu, 5 Sep 2024 18:05:00 +0300 Subject: [PATCH 1/9] Migrated most POD uses to use the DBLayer class instead of direct PDO access. --- src/API/Backend/Delete.php | 12 +- src/API/History/Index.php | 12 +- src/API/Ignore/Index.php | 8 +- src/API/System/Integrity.php | 13 +- src/API/System/Parity.php | 14 +- src/Commands/System/ReportCommand.php | 2 +- src/Libs/Database/DBLayer.php | 168 ++++++++++---- src/Libs/Database/DatabaseInterface.php | 19 +- src/Libs/Database/PDO/PDOAdapter.php | 214 +++++++++--------- src/Libs/Database/PDO/PDODataMigration.php | 25 +- src/Libs/Database/PDO/PDOIndexer.php | 8 +- src/Libs/Database/PDO/PDOMigrations.php | 18 +- ...seException.php => DBAdapterException.php} | 8 +- src/Libs/Exceptions/DBLayerException.php | 75 ++++++ tests/Database/PDOAdapterTest.php | 5 +- tests/Mappers/Import/AbstractTestsMapper.php | 11 +- 16 files changed, 365 insertions(+), 247 deletions(-) rename src/Libs/Exceptions/{DatabaseException.php => DBAdapterException.php} (84%) create mode 100644 src/Libs/Exceptions/DBLayerException.php diff --git a/src/API/Backend/Delete.php b/src/API/Backend/Delete.php index 5ab072e9..e9816aba 100644 --- a/src/API/Backend/Delete.php +++ b/src/API/Backend/Delete.php @@ -6,7 +6,7 @@ namespace App\API\Backend; use App\Libs\Config; use App\Libs\ConfigFile; -use App\Libs\Database\DatabaseInterface as iDB; +use App\Libs\Database\DBLayer; use App\Libs\Enums\Http\Status; use App\Libs\Traits\APITraits; use Psr\Http\Message\ResponseInterface as iResponse; @@ -16,12 +16,8 @@ final class Delete { use APITraits; - public function __construct(private iDB $db) - { - } - #[\App\Libs\Attributes\Route\Delete(Index::URL . '/{name:backend}[/]', name: 'backend.delete')] - public function __invoke(iRequest $request, array $args = []): iResponse + public function __invoke(DBLayer $db, iRequest $request, array $args = []): iResponse { if (null === ($name = ag($args, 'name'))) { return api_error('Invalid value for name path parameter.', Status::BAD_REQUEST); @@ -47,13 +43,13 @@ final class Delete ) "; - $stmt = $this->db->getPDO()->prepare($sql); + $stmt = $db->prepare($sql); $stmt->execute(['name_metadata' => $name, 'name_extra' => $name]); $removedReference = $stmt->rowCount(); $sql = "DELETE FROM state WHERE id IN ( SELECT id FROM state WHERE length(metadata) < 10 )"; - $stmt = $this->db->getPDO()->query($sql); + $stmt = $db->query($sql); $deletedRecords = $stmt->rowCount(); diff --git a/src/API/History/Index.php b/src/API/History/Index.php index d25b3453..a123d61d 100644 --- a/src/API/History/Index.php +++ b/src/API/History/Index.php @@ -10,6 +10,7 @@ use App\Libs\Attributes\Route\Get; use App\Libs\Attributes\Route\Route; use App\Libs\Container; use App\Libs\Database\DatabaseInterface as iDB; +use App\Libs\Database\DBLayer; use App\Libs\DataUtil; use App\Libs\Entity\StateInterface as iState; use App\Libs\Enums\Http\Status; @@ -17,7 +18,6 @@ use App\Libs\Guid; use App\Libs\Mappers\Import\DirectMapper; use App\Libs\Traits\APITraits; use JsonException; -use PDO; use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ServerRequestInterface as iRequest; use Psr\SimpleCache\CacheInterface as iCache; @@ -47,17 +47,15 @@ final class Index use APITraits; public const string URL = '%{api.prefix}/history'; - private PDO $pdo; public function __construct(private readonly iDB $db, private DirectMapper $mapper, private iCache $cache) { - $this->pdo = $this->db->getPDO(); } #[Get(self::URL . '[/]', name: 'history.list')] - public function list(iRequest $request): iResponse + public function list(DBLayer $db, iRequest $request): iResponse { - $es = fn(string $val) => $this->db->identifier($val); + $es = fn(string $val) => $db->escapeIdentifier($val, true); $data = DataUtil::fromArray($request->getQueryParams()); $filters = []; @@ -278,7 +276,7 @@ final class Index $sql[] = 'WHERE ' . implode(' AND ', $where); } - $stmt = $this->pdo->prepare('SELECT COUNT(*) ' . implode(' ', array_map('trim', $sql))); + $stmt = $db->prepare('SELECT COUNT(*) ' . implode(' ', array_map('trim', $sql))); $stmt->execute($params); $total = $stmt->fetchColumn(); @@ -324,7 +322,7 @@ final class Index $params['_limit'] = $perpage <= 0 ? 20 : $perpage; $sql[] = 'ORDER BY ' . implode(', ', $sorts) . ' LIMIT :_start,:_limit'; - $stmt = $this->pdo->prepare('SELECT * ' . implode(' ', array_map('trim', $sql))); + $stmt = $db->prepare('SELECT * ' . implode(' ', array_map('trim', $sql))); $stmt->execute($params); $getUri = $request->getUri()->withHost('')->withPort(0)->withScheme(''); diff --git a/src/API/Ignore/Index.php b/src/API/Ignore/Index.php index eb5269e3..8f543ac7 100644 --- a/src/API/Ignore/Index.php +++ b/src/API/Ignore/Index.php @@ -10,7 +10,7 @@ use App\Libs\Attributes\Route\Post; use App\Libs\Config; use App\Libs\ConfigFile; use App\Libs\Container; -use App\Libs\Database\DatabaseInterface as iDB; +use App\Libs\Database\DBLayer; use App\Libs\DataUtil; use App\Libs\Entity\StateInterface as iState; use App\Libs\Enums\Http\Status; @@ -26,14 +26,10 @@ final class Index private array $cache = []; - private PDO $db; - private ConfigFile $config; - public function __construct(iDB $db) + public function __construct(private readonly DBLayer $db) { - $this->db = $db->getPDO(); - $this->config = ConfigFile::open( file: Config::get('path') . '/config/ignore.yaml', type: 'yaml', diff --git a/src/API/System/Integrity.php b/src/API/System/Integrity.php index 5fd46689..0eb3ce6a 100644 --- a/src/API/System/Integrity.php +++ b/src/API/System/Integrity.php @@ -7,14 +7,13 @@ namespace App\API\System; use App\Libs\Attributes\Route\Delete; use App\Libs\Attributes\Route\Get; use App\Libs\Container; -use App\Libs\Database\DatabaseInterface as iDB; +use App\Libs\Database\DBLayer; use App\Libs\DataUtil; use App\Libs\Entity\StateInterface as iState; use App\Libs\Enums\Http\Status; use App\Libs\Middlewares\ExceptionHandlerMiddleware; use App\Libs\Traits\APITraits; use DateInterval; -use PDO; use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ServerRequestInterface as iRequest; use Psr\SimpleCache\CacheInterface as iCache; @@ -31,22 +30,20 @@ final class Integrity private array $checkedFile = []; private bool $fromCache = false; - private PDO $pdo; /** * @throws InvalidArgumentException */ - public function __construct(private iDB $db, private readonly iCache $cache) + public function __construct(private readonly iCache $cache) { set_time_limit(0); - $this->pdo = $this->db->getPDO(); } /** * @throws InvalidArgumentException */ #[Get(self::URL . '[/]', middleware: [ExceptionHandlerMiddleware::class], name: 'system.integrity')] - public function __invoke(iRequest $request): iResponse + public function __invoke(DBLayer $db, iRequest $request): iResponse { $params = DataUtil::fromArray($request->getQueryParams()); @@ -66,7 +63,7 @@ final class Integrity ]; $sql = "SELECT * FROM state"; - $stmt = $this->db->getPDO()->prepare($sql); + $stmt = $db->prepare($sql); $stmt->execute(); $base = Container::get(iState::class); @@ -157,7 +154,7 @@ final class Integrity * @throws InvalidArgumentException */ #[Delete(self::URL . '[/]', name: 'system.integrity.reset')] - public function resetCache(iRequest $request): iResponse + public function resetCache(): iResponse { if ($this->cache->has('system.integrity')) { $this->cache->delete('system.integrity'); diff --git a/src/API/System/Parity.php b/src/API/System/Parity.php index 39477ef7..f55f6651 100644 --- a/src/API/System/Parity.php +++ b/src/API/System/Parity.php @@ -6,11 +6,10 @@ namespace App\API\System; use App\Libs\Attributes\Route\Delete; use App\Libs\Attributes\Route\Get; -use App\Libs\Database\DatabaseInterface as iDB; +use App\Libs\Database\DBLayer; use App\Libs\DataUtil; use App\Libs\Enums\Http\Status; use App\Libs\Traits\APITraits; -use PDO; use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ServerRequestInterface as iRequest; use Psr\SimpleCache\InvalidArgumentException; @@ -21,11 +20,8 @@ final class Parity public const string URL = '%{api.prefix}/system/parity'; - private PDO $pdo; - - public function __construct(private iDB $db) + public function __construct(private readonly DBLayer $db) { - $this->pdo = $this->db->getPDO(); } /** @@ -60,7 +56,7 @@ final class Parity $counter = 0 === $counter ? $backendsCount : $counter; $sql = "SELECT COUNT(*) FROM state WHERE ( SELECT COUNT(*) FROM JSON_EACH(state.metadata) ) < {$counter}"; - $stmt = $this->pdo->query($sql); + $stmt = $this->db->query($sql); $total = (int)$stmt->fetchColumn(); $lastPage = @ceil($total / $perpage); @@ -84,7 +80,7 @@ final class Parity :_start, :_perpage "; - $stmt = $this->db->getPDO()->prepare($sql); + $stmt = $this->db->prepare($sql); $stmt->execute([ '_start' => $start, '_perpage' => $perpage, @@ -135,7 +131,7 @@ final class Parity WHERE ( SELECT COUNT(*) FROM JSON_EACH(state.metadata) ) < {$counter} "; - $stmt = $this->db->getPDO()->query($sql); + $stmt = $this->db->query($sql); return api_response(Status::OK, [ 'deleted_records' => $stmt->rowCount(), diff --git a/src/Commands/System/ReportCommand.php b/src/Commands/System/ReportCommand.php index febc3460..cf93277e 100644 --- a/src/Commands/System/ReportCommand.php +++ b/src/Commands/System/ReportCommand.php @@ -246,7 +246,7 @@ final class ReportCommand extends Command if (true === $includeSample) { $sql = "SELECT * FROM state WHERE via = :name ORDER BY updated DESC LIMIT 3"; - $stmt = $this->db->getPDO()->prepare($sql); + $stmt = $this->db->getDBLayer()->prepare($sql); $stmt->execute([ 'name' => $name, ]); diff --git a/src/Libs/Database/DBLayer.php b/src/Libs/Database/DBLayer.php index 6cf5b7b0..ddc4ecb1 100644 --- a/src/Libs/Database/DBLayer.php +++ b/src/Libs/Database/DBLayer.php @@ -5,15 +5,19 @@ declare(strict_types=1); namespace App\Libs\Database; -use App\Libs\Exceptions\DatabaseException as DBException; +use App\Libs\Exceptions\DBLayerException; use Closure; use PDO; use PDOException; use PDOStatement; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; use RuntimeException; -final class DBLayer +final class DBLayer implements LoggerAwareInterface { + use LoggerAwareTrait; + private const int LOCK_RETRY = 4; private int $count = 0; @@ -61,55 +65,71 @@ final class DBLayer public function exec(string $sql, array $options = []): int|false { try { - $queryString = $sql; + return $this->wrap(function (DBLayer $db) use ($sql, $options) { + $queryString = $sql; - $this->last = [ - 'sql' => $queryString, - 'bind' => [], - ]; + $this->last = [ + 'sql' => $queryString, + 'bind' => [], + ]; - $stmt = $this->pdo->exec($queryString); + return $db->pdo->exec($queryString); + }); } catch (PDOException $e) { - throw (new DBException($e->getMessage())) - ->setInfo($queryString, [], $e->errorInfo ?? [], $e->getCode()) - ->setFile($e->getTrace()[$options['tracer'] ?? 1]['file'] ?? $e->getFile()) - ->setLine($e->getTrace()[$options['tracer'] ?? 1]['line'] ?? $e->getLine()) - ->setOptions([]); - } - - return $stmt; - } - - public function query(string $queryString, array $bind = [], array $options = []): PDOStatement - { - try { - $this->last = [ - 'sql' => $queryString, - 'bind' => $bind, - ]; - - $stmt = $this->pdo->prepare($queryString); - - if (!($stmt instanceof PDOStatement)) { - throw new PDOException('Unable to prepare statement.'); + if ($e instanceof DBLayerException) { + throw $e; } - $stmt->execute($bind); - - if (false !== stripos($queryString, 'SQL_CALC_FOUND_ROWS')) { - if (false !== ($countStatement = $this->pdo->query('SELECT FOUND_ROWS();'))) { - $this->count = (int)$countStatement->fetch(PDO::FETCH_COLUMN); - } - } - } catch (PDOException $e) { - throw (new DBException($e->getMessage())) - ->setInfo($queryString, $bind, $e->errorInfo ?? [], $e->getCode()) + throw (new DBLayerException($e->getMessage())) + ->setInfo($sql, [], $e->errorInfo ?? [], $e->getCode()) ->setFile($e->getTrace()[$options['tracer'] ?? 1]['file'] ?? $e->getFile()) ->setLine($e->getTrace()[$options['tracer'] ?? 1]['line'] ?? $e->getLine()) ->setOptions($options); } + } - return $stmt; + public function query(string|PDOStatement $sql, array $bind = [], array $options = []): PDOStatement + { + try { + return $this->wrap(function (DBLayer $db) use ($sql, $bind, $options) { + $isStatement = $sql instanceof PDOStatement; + $queryString = $isStatement ? $sql->queryString : $sql; + + $this->last = [ + 'sql' => $queryString, + 'bind' => $bind, + ]; + + $stmt = $isStatement ? $sql : $db->prepare($sql); + if (false === ($stmt instanceof PDOStatement)) { + throw new PDOException('Unable to prepare statement.'); + } + + $stmt->execute($bind); + + if (false !== stripos($queryString, 'SQL_CALC_FOUND_ROWS')) { + if (false !== ($countStatement = $this->pdo->query('SELECT FOUND_ROWS();'))) { + $this->count = (int)$countStatement->fetch(PDO::FETCH_COLUMN); + } + } + + return $stmt; + }); + } catch (PDOException $e) { + if ($e instanceof DBLayerException) { + throw $e; + } + throw (new DBLayerException($e->getMessage(), $e->getCode(), $e)) + ->setInfo( + (true === ($sql instanceof PDOStatement)) ? $sql->queryString : $sql, + $bind, + $e->errorInfo ?? [], + $e->getCode() + ) + ->setFile($e->getTrace()[$options['tracer'] ?? 1]['file'] ?? $e->getFile()) + ->setLine($e->getTrace()[$options['tracer'] ?? 1]['line'] ?? $e->getLine()) + ->setOptions($options); + } } public function start(): bool @@ -136,6 +156,25 @@ final class DBLayer return $this->pdo->inTransaction(); } + /** + * @return bool + * @deprecated Use {@link self::start()} instead. + */ + public function beginTransaction(): bool + { + return $this->start(); + } + + public function prepare(string $sql, array $options = []): PDOStatement|false + { + return $this->pdo->prepare($sql, $options); + } + + public function lastInsertId(): string|false + { + return $this->pdo->lastInsertId(); + } + public function delete(string $table, array $conditions, array $options = []): PDOStatement { if (empty($conditions)) { @@ -422,6 +461,11 @@ final class DBLayer return $this->driver; } + public function getBackend(): PDO + { + return $this->pdo; + } + private function conditionParser(array $conditions): array { $keys = $bind = []; @@ -699,21 +743,22 @@ final class DBLayer for ($i = 1; $i <= self::LOCK_RETRY; $i++) { try { - if (!$autoStartTransaction) { + if (true === $autoStartTransaction) { $this->start(); } $result = $callback($this); - if (!$autoStartTransaction) { + if (true === $autoStartTransaction) { $this->commit(); } $this->last = $this->getLastStatement(); return $result; - } catch (DBException $e) { - if (!$autoStartTransaction && $this->inTransaction()) { + } catch (DBLayerException $e) { + /** @noinspection PhpConditionAlreadyCheckedInspection */ + if ($autoStartTransaction && $this->inTransaction()) { $this->rollBack(); } @@ -737,4 +782,37 @@ final class DBLayer */ return null; } + + private function wrap(Closure $callback): mixed + { + for ($i = 0; $i <= self::LOCK_RETRY; $i++) { + try { + return $callback($this); + } catch (PDOException $e) { + if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) { + if ($i >= self::LOCK_RETRY) { + throw (new DBLayerException($e->getMessage(), $e->getCode(), $e)) + ->setInfo($stnt->queryString, $bind, $e->errorInfo ?? [], $e->getCode()) + ->setFile($e->getFile()) + ->setLine($e->getLine()); + } + + $sleep = self::LOCK_RETRY + random_int(1, 3); + + $this->logger?->warning("PDOAdapter: Database is locked. sleeping for '{sleep}s'.", [ + 'sleep' => $sleep + ]); + + sleep($sleep); + } else { + throw (new DBLayerException($e->getMessage(), $e->getCode(), $e)) + ->setInfo($stnt->queryString, $bind, $e->errorInfo ?? [], $e->getCode()) + ->setFile($e->getFile()) + ->setLine($e->getLine()); + } + } + } + + return false; + } } diff --git a/src/Libs/Database/DatabaseInterface.php b/src/Libs/Database/DatabaseInterface.php index cb179676..0099ea49 100644 --- a/src/Libs/Database/DatabaseInterface.php +++ b/src/Libs/Database/DatabaseInterface.php @@ -7,10 +7,8 @@ namespace App\Libs\Database; use App\Libs\Entity\StateInterface; use Closure; use DateTimeInterface; -use PDO; use PDOException; use Psr\Log\LoggerInterface; -use RuntimeException; interface DatabaseInterface { @@ -55,15 +53,6 @@ interface DatabaseInterface */ public function getAll(DateTimeInterface|null $date = null, array $opts = []): array; - /** - * Return number of items. - * - * @param DateTimeInterface|null $date if provided, it will return items changes since this date. - * - * @return int Number of items. - */ - public function getCount(DateTimeInterface|null $date = null): int; - /** * Return database records for given items. * @@ -184,13 +173,11 @@ interface DatabaseInterface public function setLogger(LoggerInterface $logger): self; /** - * Get PDO instance. + * Get DBLayer instance. * - * @return PDO - * - * @throws RuntimeException if PDO is not initialized yet. + * @return DBLayer */ - public function getPDO(): PDO; + public function getDBLayer(): DBLayer; /** * Enable single transaction mode. diff --git a/src/Libs/Database/PDO/PDOAdapter.php b/src/Libs/Database/PDO/PDOAdapter.php index 8b8a982f..e3895f56 100644 --- a/src/Libs/Database/PDO/PDOAdapter.php +++ b/src/Libs/Database/PDO/PDOAdapter.php @@ -6,8 +6,10 @@ namespace App\Libs\Database\PDO; use App\Libs\Container; use App\Libs\Database\DatabaseInterface as iDB; +use App\Libs\Database\DBLayer; use App\Libs\Entity\StateInterface as iState; -use App\Libs\Exceptions\DatabaseException as DBException; +use App\Libs\Exceptions\DBAdapterException as DBException; +use App\Libs\Exceptions\DBLayerException; use App\Libs\Options; use Closure; use DateTimeInterface; @@ -34,14 +36,17 @@ final class PDOAdapter implements iDB * @var bool Whether the current operation is in a transaction. */ private bool $viaTransaction = false; + /** * @var bool Whether the current operation is using a single transaction. */ private bool $singleTransaction = false; + /** * @var array Adapter options. */ private array $options = []; + /** * @var array Prepared statements. */ @@ -49,6 +54,7 @@ final class PDOAdapter implements iDB 'insert' => null, 'update' => null, ]; + /** * @var string The database driver to be used. */ @@ -58,15 +64,11 @@ final class PDOAdapter implements iDB * Creates a new instance of the class. * * @param LoggerInterface $logger The logger object used for logging. - * @param PDO $pdo The PDO object used for database connections. + * @param DBLayer $db The PDO object used for database connections. */ - public function __construct(private LoggerInterface $logger, private PDO $pdo) + public function __construct(private LoggerInterface $logger, private readonly DBLayer $db) { - $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); - - if (is_string($driver)) { - $this->driver = $driver; - } + $this->driver = $this->db->getDriver(); } /** @@ -151,14 +153,14 @@ final class PDOAdapter implements iDB } if (null === ($this->stmt['insert'] ?? null)) { - $this->stmt['insert'] = $this->pdo->prepare( + $this->stmt['insert'] = $this->db->prepare( $this->pdoInsert('state', iState::ENTITY_KEYS) ); } $this->execute($this->stmt['insert'], $data); - $entity->id = (int)$this->pdo->lastInsertId(); + $entity->id = (int)$this->db->lastInsertId(); } catch (PDOException $e) { $this->stmt['insert'] = null; if (false === $this->viaTransaction && false === $this->singleTransaction) { @@ -282,21 +284,6 @@ final class PDOAdapter implements iDB return $arr; } - /** - * @inheritdoc - * @throws RandomException if an error occurs while generating a random number. - */ - public function getCount(DateTimeInterface|null $date = null): int - { - $sql = 'SELECT COUNT(id) AS total FROM state'; - - if (null !== $date) { - $sql .= ' WHERE ' . iState::COLUMN_UPDATED . ' > ' . $date->getTimestamp(); - } - - return (int)$this->query($sql)->fetchColumn(); - } - /** * @inheritdoc * @throws RandomException if an error occurs while generating a random number. @@ -334,7 +321,7 @@ final class PDOAdapter implements iDB } $sql = "SELECT * FROM state WHERE {$type_sql} JSON_EXTRACT(" . iState::COLUMN_META_DATA . ",'$.{$key}') = :id LIMIT 1"; - $stmt = $this->pdo->prepare($sql); + $stmt = $this->db->prepare($sql); if (false === $this->execute($stmt, $cond)) { throw new DBException( @@ -404,7 +391,7 @@ final class PDOAdapter implements iDB } if (null === ($this->stmt['update'] ?? null)) { - $this->stmt['update'] = $this->pdo->prepare( + $this->stmt['update'] = $this->db->prepare( $this->pdoUpdate('state', iState::ENTITY_KEYS) ); } @@ -554,16 +541,14 @@ final class PDOAdapter implements iDB */ public function migrations(string $dir, array $opts = []): mixed { - $class = new PDOMigrations($this->pdo, $this->logger); + $class = new PDOMigrations($this->db, $this->logger); return match (strtolower($dir)) { iDB::MIGRATE_UP => $class->up(), iDB::MIGRATE_DOWN => $class->down(), - default => throw new DBException( - r("PDOAdapter: Unknown migration direction '{dir}' was given.", [ - 'name' => $dir - ]), 91 - ), + default => throw new DBException(r("PDOAdapter: Unknown migration direction '{dir}' was given.", [ + 'name' => $dir + ]), 91), }; } @@ -572,7 +557,7 @@ final class PDOAdapter implements iDB */ public function ensureIndex(array $opts = []): mixed { - return (new PDOIndexer($this->pdo, $this->logger))->ensureIndex($opts); + return (new PDOIndexer($this->db, $this->logger))->ensureIndex($opts); } /** @@ -580,7 +565,7 @@ final class PDOAdapter implements iDB */ public function migrateData(string $version, LoggerInterface|null $logger = null): mixed { - return (new PDODataMigration($this->pdo, $logger ?? $this->logger))->automatic(); + return (new PDODataMigration($this->db, $logger ?? $this->logger))->automatic(); } /** @@ -588,7 +573,7 @@ final class PDOAdapter implements iDB */ public function isMigrated(): bool { - return (new PDOMigrations($this->pdo, $this->logger))->isMigrated(); + return (new PDOMigrations($this->db, $this->logger))->isMigrated(); } /** @@ -596,7 +581,7 @@ final class PDOAdapter implements iDB */ public function makeMigration(string $name, array $opts = []): mixed { - return (new PDOMigrations($this->pdo, $this->logger))->make($name); + return (new PDOMigrations($this->db, $this->logger))->make($name); } /** @@ -604,7 +589,7 @@ final class PDOAdapter implements iDB */ public function maintenance(array $opts = []): mixed { - return (new PDOMigrations($this->pdo, $this->logger))->runMaintenance(); + return (new PDOMigrations($this->db, $this->logger))->runMaintenance(); } /** @@ -613,19 +598,19 @@ final class PDOAdapter implements iDB */ public function reset(): bool { - $this->pdo->beginTransaction(); + $this->db->transactional(function (DBLayer $db) { + /** @noinspection SqlResolve */ + $tables = $db->query( + 'SELECT name FROM sqlite_master WHERE "type" = "table" AND "name" NOT LIKE "sqlite_%"' + ); - $tables = $this->pdo->query( - 'SELECT name FROM sqlite_master WHERE "type" = "table" AND "name" NOT LIKE "sqlite_%"' - ); + foreach ($tables->fetchAll(PDO::FETCH_COLUMN) as $table) { + $db->exec('DELETE FROM "' . $table . '"'); + $db->exec('DELETE FROM sqlite_sequence WHERE "name" = "' . $table . '"'); + } + }); - foreach ($tables->fetchAll(PDO::FETCH_COLUMN) as $table) { - $this->pdo->exec('DELETE FROM "' . $table . '"'); - $this->pdo->exec('DELETE FROM sqlite_sequence WHERE "name" = "' . $table . '"'); - } - - $this->pdo->commit(); - $this->pdo->exec('VACUUM'); + $this->db->exec('VACUUM'); return true; } @@ -640,12 +625,9 @@ final class PDOAdapter implements iDB return $this; } - /** - * @inheritdoc - */ - public function getPDO(): PDO + public function getDBLayer(): DBLayer { - return $this->pdo; + return $this->db; } /** @@ -655,11 +637,11 @@ final class PDOAdapter implements iDB { $this->singleTransaction = true; - if (false === $this->pdo->inTransaction()) { - $this->pdo->beginTransaction(); + if (false === $this->db->inTransaction()) { + $this->db->start(); } - return $this->pdo->inTransaction(); + return $this->db->inTransaction(); } /** @@ -667,7 +649,7 @@ final class PDOAdapter implements iDB */ public function transactional(Closure $callback): mixed { - if (true === $this->pdo->inTransaction()) { + if (true === $this->db->inTransaction()) { $this->viaTransaction = true; $result = $callback($this); $this->viaTransaction = false; @@ -675,19 +657,20 @@ final class PDOAdapter implements iDB } try { - $this->pdo->beginTransaction(); + $this->db->start(); $this->viaTransaction = true; $result = $callback($this); $this->viaTransaction = false; - $this->pdo->commit(); + $this->db->commit(); return $result; } catch (PDOException $e) { - $this->pdo->rollBack(); - $this->viaTransaction = false; + $this->db->rollBack(); throw $e; + } finally { + $this->viaTransaction = false; } } @@ -701,8 +684,8 @@ final class PDOAdapter implements iDB */ public function __destruct() { - if (true === $this->singleTransaction && true === $this->pdo->inTransaction()) { - $this->pdo->commit(); + if (true === $this->singleTransaction && true === $this->db->inTransaction()) { + $this->db->commit(); } $this->stmt = []; @@ -827,7 +810,7 @@ final class PDOAdapter implements iDB $sql = "SELECT * FROM state WHERE " . iState::COLUMN_TYPE . " = :type {$sqlEpisode} {$sqlGuids} LIMIT 1"; - $stmt = $this->pdo->prepare($sql); + $stmt = $this->db->prepare($sql); if (false === $this->execute($stmt, $cond)) { throw new DBException( @@ -857,29 +840,7 @@ final class PDOAdapter implements iDB */ private function execute(PDOStatement $stmt, array $cond = []): bool { - for ($i = 0; $i <= self::LOCK_RETRY; $i++) { - try { - return $stmt->execute($cond); - } catch (PDOException $e) { - if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) { - if ($i >= self::LOCK_RETRY) { - throw $e; - } - - $sleep = self::LOCK_RETRY + random_int(1, 3); - - $this->logger->warning("PDOAdapter: Database is locked. sleeping for '{sleep}s'.", [ - 'sleep' => $sleep - ]); - - sleep($sleep); - } else { - throw $e; - } - } - } - - return false; + return $this->wrap(fn(PDOAdapter $adapter) => $stmt->execute($cond)); } /** @@ -895,29 +856,7 @@ final class PDOAdapter implements iDB */ private function query(string $sql): PDOStatement|false { - for ($i = 0; $i <= self::LOCK_RETRY; $i++) { - try { - return $this->pdo->query($sql); - } catch (PDOException $e) { - if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) { - if ($i >= self::LOCK_RETRY) { - throw $e; - } - - $sleep = self::LOCK_RETRY + random_int(1, 3); - - $this->logger->warning("PDOAdapter: Database is locked. sleeping for '{sleep}s'.", [ - 'sleep' => $sleep, - ]); - - sleep($sleep); - } else { - throw $e; - } - } - } - - return false; + return $this->wrap(fn(PDOAdapter $adapter) => $adapter->db->query($sql)); } /** @@ -981,4 +920,57 @@ final class PDOAdapter implements iDB default => '"' . $text . '"', }; } + + /** + * Wraps the given callback function with a retry mechanism to handle database locks. + * + * @param Closure $callback The callback function to be executed. + * + * @return mixed The result of the callback function. + * + * @throws DBLayerException If an error occurs while executing the callback function. + * @throws RandomException If an error occurs while generating a random number. + */ + private function wrap(Closure $callback): mixed + { + for ($i = 0; $i <= self::LOCK_RETRY; $i++) { + try { + return $callback($this); + } catch (PDOException $e) { + if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) { + if ($i >= self::LOCK_RETRY) { + throw (new DBLayerException($e->getMessage(), $e->getCode(), $e)) + ->setInfo( + ag($this->db->getLastStatement(), 'sql', ''), + ag($this->db->getLastStatement(), 'bind', []), + $e->errorInfo ?? [], + $e->getCode() + ) + ->setFile($e->getFile()) + ->setLine($e->getLine()); + } + + $sleep = self::LOCK_RETRY + random_int(1, 3); + + $this->logger->warning("PDOAdapter: Database is locked. sleeping for '{sleep}s'.", [ + 'sleep' => $sleep + ]); + + sleep($sleep); + } else { + throw (new DBLayerException($e->getMessage(), $e->getCode(), $e)) + ->setInfo( + ag($this->db->getLastStatement(), 'sql', ''), + ag($this->db->getLastStatement(), 'bind', []), + $e->errorInfo ?? [], + $e->getCode() + ) + ->setFile($e->getFile()) + ->setLine($e->getLine()); + } + } + } + + return false; + } } diff --git a/src/Libs/Database/PDO/PDODataMigration.php b/src/Libs/Database/PDO/PDODataMigration.php index 42efa832..f45cbb9f 100644 --- a/src/Libs/Database/PDO/PDODataMigration.php +++ b/src/Libs/Database/PDO/PDODataMigration.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Libs\Database\PDO; use App\Libs\Config; +use App\Libs\Database\DBLayer; use App\Libs\Entity\StateInterface as iFace; use App\Libs\Guid; use PDO; @@ -41,10 +42,10 @@ final class PDODataMigration /** * Class constructor. * - * @param PDO $pdo The PDO instance to use for database connection. + * @param DBLayer $db The PDO instance to use for database connection. * @param LoggerInterface $logger The logger instance to use for logging. */ - public function __construct(private PDO $pdo, private LoggerInterface $logger) + public function __construct(private DBLayer $db, private LoggerInterface $logger) { $this->version = Config::get('database.version'); $this->dbPath = dirname(after(Config::get('database.dsn'), 'sqlite:')); @@ -125,15 +126,15 @@ final class PDODataMigration ] ); - if (!$this->pdo->inTransaction()) { - $this->pdo->beginTransaction(); + if (!$this->db->inTransaction()) { + $this->db->start(); } $columns = implode(', ', iFace::ENTITY_KEYS); $binds = ':' . implode(', :', iFace::ENTITY_KEYS); /** @noinspection SqlInsertValues */ - $insert = $this->pdo->prepare("INSERT INTO state ({$columns}) VALUES({$binds})"); + $insert = $this->db->prepare("INSERT INTO state ({$columns}) VALUES({$binds})"); $stmt = $oldDB->query("SELECT * FROM state"); @@ -230,8 +231,8 @@ final class PDODataMigration ]); } - if ($this->pdo->inTransaction()) { - $this->pdo->commit(); + if ($this->db->inTransaction()) { + $this->db->commit(); } $stmt = null; @@ -290,15 +291,15 @@ final class PDODataMigration PDO::SQLITE_ATTR_OPEN_FLAGS => PDO::SQLITE_OPEN_READONLY, ]); - if (!$this->pdo->inTransaction()) { - $this->pdo->beginTransaction(); + if (!$this->db->inTransaction()) { + $this->db->start(); } $columns = implode(', ', iFace::ENTITY_KEYS); $binds = ':' . implode(', :', iFace::ENTITY_KEYS); /** @noinspection SqlInsertValues */ - $insert = $this->pdo->prepare("INSERT INTO state ({$columns}) VALUES({$binds})"); + $insert = $this->db->prepare("INSERT INTO state ({$columns}) VALUES({$binds})"); foreach ($oldDB->query("SELECT * FROM state") as $row) { $row[iFace::COLUMN_EXTRA] = json_decode( @@ -419,8 +420,8 @@ final class PDODataMigration $insert->execute($arr); } - if ($this->pdo->inTransaction()) { - $this->pdo->commit(); + if ($this->db->inTransaction()) { + $this->db->commit(); } $oldDB = null; diff --git a/src/Libs/Database/PDO/PDOIndexer.php b/src/Libs/Database/PDO/PDOIndexer.php index f7a6516a..36ca1fa7 100644 --- a/src/Libs/Database/PDO/PDOIndexer.php +++ b/src/Libs/Database/PDO/PDOIndexer.php @@ -5,10 +5,10 @@ declare(strict_types=1); namespace App\Libs\Database\PDO; use App\Libs\Config; +use App\Libs\Database\DBLayer; use App\Libs\Entity\StateInterface as iState; use App\Libs\Guid; use App\Libs\Options; -use PDO; use Psr\Log\LoggerInterface as iLogger; /** @@ -40,10 +40,10 @@ final class PDOIndexer /** * Class constructor. * - * @param PDO $db The PDO object used for database connections and queries. + * @param DBLayer $db The PDO object used for database connections and queries. * @param iLogger $logger The logger object used for logging information. */ - public function __construct(private PDO $db, private iLogger $logger) + public function __construct(private DBLayer $db, private iLogger $logger) { } @@ -169,7 +169,7 @@ final class PDOIndexer if (!$this->db->inTransaction()) { $startedTransaction = true; - $this->db->beginTransaction(); + $this->db->start(); } foreach ($queries as $query) { diff --git a/src/Libs/Database/PDO/PDOMigrations.php b/src/Libs/Database/PDO/PDOMigrations.php index d42e2046..6eea3e5a 100644 --- a/src/Libs/Database/PDO/PDOMigrations.php +++ b/src/Libs/Database/PDO/PDOMigrations.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace App\Libs\Database\PDO; use App\Libs\Database\DatabaseInterface as iDB; +use App\Libs\Database\DBLayer; use App\Libs\Stream; -use PDO; use Psr\Log\LoggerInterface; use RuntimeException; use SplFileObject; @@ -36,12 +36,12 @@ final class PDOMigrations /** * Constructs a new instance of the class. * - * @param PDO $pdo The database connection object. + * @param DBLayer $db The database connection object. * @param LoggerInterface $logger The logger instance. * * @return void */ - public function __construct(private PDO $pdo, private LoggerInterface $logger) + public function __construct(private DBLayer $db, private LoggerInterface $logger) { $this->path = __DIR__ . '/../../../../migrations'; $this->driver = $this->getDriver(); @@ -119,7 +119,7 @@ final class PDOMigrations 'name' => ag($migrate, 'name') ])); - $this->pdo->exec((string)ag($migrate, iDB::MIGRATE_UP)); + $this->db->exec((string)ag($migrate, iDB::MIGRATE_UP)); $this->setVersion(ag($migrate, 'id')); } @@ -200,7 +200,7 @@ final class PDOMigrations public function runMaintenance(): int|bool { if ('sqlite' === $this->driver) { - return $this->pdo->exec('VACUUM;'); + return $this->db->exec('VACUUM;'); } return false; @@ -213,7 +213,7 @@ final class PDOMigrations */ private function getVersion(): int { - return (int)$this->pdo->query('PRAGMA user_version')->fetchColumn(); + return (int)$this->db->query('PRAGMA user_version')->fetchColumn(); } /** @@ -225,7 +225,7 @@ final class PDOMigrations */ private function setVersion(int $version): void { - $this->pdo->exec('PRAGMA user_version = ' . $version); + $this->db->exec('PRAGMA user_version = ' . $version); } /** @@ -235,9 +235,9 @@ final class PDOMigrations */ private function getDriver(): string { - $driver = $this->pdo->getAttribute($this->pdo::ATTR_DRIVER_NAME); + $driver = $this->db->getDriver(); - if (empty($driver) || !is_string($driver)) { + if (empty($driver)) { $driver = 'unknown'; } diff --git a/src/Libs/Exceptions/DatabaseException.php b/src/Libs/Exceptions/DBAdapterException.php similarity index 84% rename from src/Libs/Exceptions/DatabaseException.php rename to src/Libs/Exceptions/DBAdapterException.php index c12559dd..efb3eee8 100644 --- a/src/Libs/Exceptions/DatabaseException.php +++ b/src/Libs/Exceptions/DBAdapterException.php @@ -12,7 +12,7 @@ use RuntimeException; * The DatabaseException class extends the RuntimeException class and represents an exception * that is thrown when there is an error related to the database operation. */ -class DatabaseException extends RuntimeException +class DBAdapterException extends RuntimeException { public string $queryString = ''; public array $bind = []; @@ -52,21 +52,21 @@ class DatabaseException extends RuntimeException return $this->bind; } - public function setFile(string $file): DatabaseException + public function setFile(string $file): DBAdapterException { $this->file = $file; return $this; } - public function setLine(int $line): DatabaseException + public function setLine(int $line): DBAdapterException { $this->line = $line; return $this; } - public function setOptions(array $options): DatabaseException + public function setOptions(array $options): DBAdapterException { $this->options = $options; diff --git a/src/Libs/Exceptions/DBLayerException.php b/src/Libs/Exceptions/DBLayerException.php new file mode 100644 index 00000000..a2fb0e57 --- /dev/null +++ b/src/Libs/Exceptions/DBLayerException.php @@ -0,0 +1,75 @@ +queryString = $queryString; + $this->bind = $bind; + $this->errorInfo = $errorInfo; + $this->code = $errorCode; + + return $this; + } + + public function getQueryString(): string + { + return $this->queryString; + } + + public function getQueryBind(): array + { + return $this->bind; + } + + public function setFile(string $file): DBLayerException + { + $this->file = $file; + + return $this; + } + + public function setLine(int $line): DBLayerException + { + $this->line = $line; + + return $this; + } + + public function setOptions(array $options): DBLayerException + { + $this->options = $options; + + return $this; + } +} diff --git a/tests/Database/PDOAdapterTest.php b/tests/Database/PDOAdapterTest.php index 7b3090a2..3c09b9c0 100644 --- a/tests/Database/PDOAdapterTest.php +++ b/tests/Database/PDOAdapterTest.php @@ -6,10 +6,11 @@ declare(strict_types=1); namespace Tests\Database; use App\Libs\Database\DatabaseInterface as iDB; +use App\Libs\Database\DBLayer; use App\Libs\Database\PDO\PDOAdapter; use App\Libs\Entity\StateEntity; use App\Libs\Entity\StateInterface; -use App\Libs\Exceptions\DatabaseException as DBException; +use App\Libs\Exceptions\DBAdapterException as DBException; use App\Libs\Guid; use App\Libs\TestCase; use DateTimeImmutable; @@ -46,7 +47,7 @@ class PDOAdapterTest extends TestCase $logger->pushHandler($this->handler); Guid::setLogger($logger); - $this->db = new PDOAdapter($logger, new PDO('sqlite::memory:')); + $this->db = new PDOAdapter($logger, new DBLayer(new PDO('sqlite::memory:'))); $this->db->migrations('up'); } diff --git a/tests/Mappers/Import/AbstractTestsMapper.php b/tests/Mappers/Import/AbstractTestsMapper.php index 2ccbbeca..ebe4cf83 100644 --- a/tests/Mappers/Import/AbstractTestsMapper.php +++ b/tests/Mappers/Import/AbstractTestsMapper.php @@ -5,10 +5,11 @@ declare(strict_types=1); namespace Tests\Mappers\Import; use App\Libs\Database\DatabaseInterface as iDB; +use App\Libs\Database\DBLayer; use App\Libs\Database\PDO\PDOAdapter; use App\Libs\Entity\StateEntity; use App\Libs\Entity\StateInterface as iState; -use App\Libs\Exceptions\DatabaseException; +use App\Libs\Exceptions\DBAdapterException; use App\Libs\Extends\LogMessageProcessor; use App\Libs\Guid; use App\Libs\Mappers\ImportInterface; @@ -51,7 +52,7 @@ abstract class AbstractTestsMapper extends TestCase $this->logger->pushHandler($this->handler); Guid::setLogger($this->logger); - $this->db = new PDOAdapter($this->logger, new PDO('sqlite::memory:')); + $this->db = new PDOAdapter($this->logger, new DBLayer(new PDO('sqlite::memory:'))); $this->db->migrations('up'); $this->mapper = $this->setupMapper(); @@ -529,7 +530,7 @@ abstract class AbstractTestsMapper extends TestCase { $testEpisode = new StateEntity($this->testEpisode); $testEpisode->episode = 0; - $this->expectException(DatabaseException::class); + $this->expectException(DBAdapterException::class); $this->db->commit([$testEpisode]); } @@ -540,7 +541,7 @@ abstract class AbstractTestsMapper extends TestCase { $testEpisode = new StateEntity($this->testEpisode); $testEpisode->episode = 0; - $this->expectException(DatabaseException::class); + $this->expectException(DBAdapterException::class); $this->db->insert($testEpisode); } @@ -551,7 +552,7 @@ abstract class AbstractTestsMapper extends TestCase { $testEpisode = new StateEntity($this->testEpisode); $testEpisode->episode = 0; - $this->expectException(DatabaseException::class); + $this->expectException(DBAdapterException::class); $this->db->update($testEpisode); } From 49c78b437b9e99265b57d0a2aacd321395494ff8 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A." Date: Thu, 5 Sep 2024 18:34:54 +0300 Subject: [PATCH 2/9] Enhance mappers db errors with the sql & bind parameters. --- src/Backends/Common/Error.php | 14 +- src/Libs/Mappers/Import/DirectMapper.php | 194 +++++++++++++---------- src/Libs/Mappers/Import/MemoryMapper.php | 19 ++- src/Libs/helpers.php | 33 ++++ 4 files changed, 170 insertions(+), 90 deletions(-) diff --git a/src/Backends/Common/Error.php b/src/Backends/Common/Error.php index 16925077..1f9a8cf4 100644 --- a/src/Backends/Common/Error.php +++ b/src/Backends/Common/Error.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Backends\Common; +use App\Libs\Database\DBLayer; +use App\Libs\Exceptions\DBLayerException; use Stringable; use Throwable; @@ -67,7 +69,17 @@ final readonly class Error implements Stringable return $this->message; } - return r($this->message, $this->context, ['log_behavior' => true]); + $context = $this->context; + + if (true === ($this->previous instanceof DBLayerException)) { + $context[DBLayer::class] = [ + 'query' => $this->previous->getQueryString(), + 'bind' => $this->previous->getQueryBind(), + 'error' => $this->previous->errorInfo ?? [], + ]; + } + + return r($this->message, $context, ['log_behavior' => true]); } public function __toString(): string diff --git a/src/Libs/Mappers/Import/DirectMapper.php b/src/Libs/Mappers/Import/DirectMapper.php index 714bf08b..1ae65cd9 100644 --- a/src/Libs/Mappers/Import/DirectMapper.php +++ b/src/Libs/Mappers/Import/DirectMapper.php @@ -120,7 +120,7 @@ final class DirectMapper implements iImport $this->addPointers($entity, $pointer); } - $this->logger->info("MAPPER: Preloaded '{pointers}' pointers into memory.", [ + $this->logger->info("DirectMapper: Preloaded '{pointers}' pointers into memory.", [ 'mapper' => afterLast(self::class, '\\'), 'pointers' => number_format(count($this->pointers)), ]); @@ -147,7 +147,7 @@ final class DirectMapper implements iImport Message::increment("{$entity->via}.{$entity->type}.failed"); $this->logger->notice( - "MAPPER: Ignoring '{backend}' '{title}'. Does not exist in database. And backend set as metadata source only.", + "DirectMapper: Ignoring '{backend}: {title}'. Does not exist in database. And backend set as metadata source only.", [ 'metaOnly' => true, 'backend' => $entity->via, @@ -191,7 +191,7 @@ final class DirectMapper implements iImport } } - $this->logger->notice("MAPPER: '{backend}' added '{title}' as new item.", [ + $this->logger->notice("DirectMapper: '{backend}' added '{title}' as new item.", [ 'backend' => $entity->via, 'title' => $entity->getName(), true === $this->inTraceMode() ? 'trace' : 'metadata' => $data, @@ -209,18 +209,21 @@ final class DirectMapper implements iImport $this->actions[$entity->type]['failed']++; Message::increment("{$entity->via}.{$entity->type}.failed"); $this->logger->error( - message: "MAPPER: Exception '{error.kind}' was thrown unhandled in adding '{backend}' '{title}'. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "DirectMapper: Exception '{error.kind}' was thrown unhandled in adding '{backend}: {title}'. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'backend' => $entity->via, + 'title' => $entity->getName(), + 'state' => $entity->getAll() ], - 'backend' => $entity->via, - 'title' => $entity->getName(), - 'state' => $entity->getAll() - ] + e: $e + ) ); } @@ -248,7 +251,7 @@ final class DirectMapper implements iImport $this->removePointers($local)->addPointers($local, $local->id); - $this->logger->notice("MAPPER: '{backend}' updated '{title}' metadata.", [ + $this->logger->notice("DirectMapper: '{backend}' updated '{title}' metadata.", [ 'id' => $local->id, 'backend' => $entity->via, 'title' => $local->getName(), @@ -269,22 +272,25 @@ final class DirectMapper implements iImport $this->actions[$local->type]['failed']++; Message::increment("{$entity->via}.{$local->type}.failed"); $this->logger->error( - message: "MAPPER: Exception '{error.kind}' was thrown unhandled in '{backend}' '{title}' handle tainted. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "DirectMapper: Exception '{error.kind}' was thrown unhandled in '{backend}: {title}' handle tainted. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'id' => $local->id, + 'backend' => $entity->via, + 'title' => $local->getName(), + 'state' => [ + 'database' => $local->getAll(), + 'backend' => $entity->getAll() + ], ], - 'id' => $local->id, - 'backend' => $entity->via, - 'title' => $local->getName(), - 'state' => [ - 'database' => $local->getAll(), - 'backend' => $entity->getAll() - ], - ] + e: $e + ) ); } @@ -306,7 +312,7 @@ final class DirectMapper implements iImport } $this->logger->notice( - "MAPPER: '{backend}' item '{id}: {title}' is marked as '{state}' vs local state '{local_state}', However due to the following reason '{reasons}' it was not considered as valid state.", + "DirectMapper: '{backend}' item '{id}: {title}' is marked as '{state}' vs local state '{local_state}', However due to the following reason '{reasons}' it was not considered as valid state.", [ 'id' => $local->id, 'backend' => $entity->via, @@ -321,7 +327,7 @@ final class DirectMapper implements iImport } if ($this->inTraceMode()) { - $this->logger->info("MAPPER: '{backend}' '{title}' No metadata changes detected.", [ + $this->logger->info("DirectMapper: '{backend}: {title}' No metadata changes detected.", [ 'id' => $local->id, 'backend' => $entity->via, 'title' => $local->getName(), @@ -364,7 +370,7 @@ final class DirectMapper implements iImport } } - $this->logger->notice("MAPPER: '{backend}' marked '{title}' as 'unplayed'.", [ + $this->logger->notice("DirectMapper: '{backend}' marked '{title}' as 'unplayed'.", [ 'id' => $cloned->id, 'backend' => $entity->via, 'title' => $cloned->getName(), @@ -381,22 +387,25 @@ final class DirectMapper implements iImport $this->actions[$local->type]['failed']++; Message::increment("{$entity->via}.{$local->type}.failed"); $this->logger->error( - message: "MAPPER: Exception '{error.kind}' was thrown unhandled in '{backend}' '{title}' handle old entity unplayed. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "DirectMapper: Exception '{error.kind}' was thrown unhandled in '{backend}: {title}' handle old entity unplayed. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'id' => $cloned->id, + 'backend' => $entity->via, + 'title' => $cloned->getName(), + 'state' => [ + 'database' => $cloned->getAll(), + 'backend' => $entity->getAll() + ], ], - 'id' => $cloned->id, - 'backend' => $entity->via, - 'title' => $cloned->getName(), - 'state' => [ - 'database' => $cloned->getAll(), - 'backend' => $entity->getAll() - ], - ] + e: $e + ) ); } @@ -428,7 +437,7 @@ final class DirectMapper implements iImport } $local = $local->apply($entity, fields: $_keys); $this->logger->notice( - $progress ? "MAPPER: '{backend}' updated '{title}' due to play progress change." : "MAPPER: '{backend}' updated '{title}' metadata.", + $progress ? "DirectMapper: '{backend}' updated '{title}' due to play progress change." : "DirectMapper: '{backend}' updated '{title}' metadata.", [ 'id' => $cloned->id, 'backend' => $entity->via, @@ -463,22 +472,25 @@ final class DirectMapper implements iImport $this->actions[$local->type]['failed']++; Message::increment("{$entity->via}.{$local->type}.failed"); $this->logger->error( - message: "MAPPER: Exception '{error.kind}' was thrown unhandled in '{backend}' '{title}' handle old entity always update metadata. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "DirectMapper: Exception '{error.kind}' was thrown unhandled in '{backend}: {title}' handle old entity always update metadata. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'id' => $cloned->id, + 'backend' => $entity->via, + 'title' => $cloned->getName(), + 'state' => [ + 'database' => $cloned->getAll(), + 'backend' => $entity->getAll() + ], ], - 'id' => $cloned->id, - 'backend' => $entity->via, - 'title' => $cloned->getName(), - 'state' => [ - 'database' => $cloned->getAll(), - 'backend' => $entity->getAll() - ], - ] + e: $e + ) ); } @@ -490,7 +502,7 @@ final class DirectMapper implements iImport if ($entity->isWatched() !== $local->isWatched()) { $this->logger->notice( - "MAPPER: '{backend}' item '{id}: {title}' is marked as '{state}' vs local state '{local_state}', However due to the remote item date '{remote_date}' being older than the last backend sync date '{local_date}'. it was not considered as valid state.", + "DirectMapper: '{backend}' item '{id}: {title}' is marked as '{state}' vs local state '{local_state}', However due to the remote item date '{remote_date}' being older than the last backend sync date '{local_date}'. it was not considered as valid state.", [ 'id' => $cloned->id, 'backend' => $entity->via, @@ -505,7 +517,7 @@ final class DirectMapper implements iImport } if ($this->inTraceMode()) { - $this->logger->debug("MAPPER: Ignoring '{backend}' '{title}'. No changes detected.", [ + $this->logger->debug("DirectMapper: Ignoring '{backend}: {title}'. No changes detected.", [ 'id' => $cloned->id, 'backend' => $entity->via, 'title' => $cloned->getName(), @@ -521,7 +533,7 @@ final class DirectMapper implements iImport public function add(iState $entity, array $opts = []): self { if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) { - $this->logger->warning("MAPPER: Ignoring '{backend}' '{title}'. No valid/supported external ids.", [ + $this->logger->warning("DirectMapper: Ignoring '{backend}: {title}'. No valid/supported external ids.", [ 'id' => $entity->id, 'backend' => $entity->via, 'title' => $entity->getName(), @@ -532,7 +544,7 @@ final class DirectMapper implements iImport if (true === $entity->isEpisode() && $entity->episode < 1) { $this->logger->warning( - "MAPPER: Ignoring '{backend}' '{id}: {title}'. Item was marked as episode but no episode number was provided.", + "DirectMapper: Ignoring '{backend}' '{id}: {title}'. Item was marked as episode but no episode number was provided.", [ 'id' => $entity->id ?? ag($entity->getMetadata($entity->via), iState::COLUMN_ID, ''), 'backend' => $entity->via, @@ -584,7 +596,7 @@ final class DirectMapper implements iImport * 3 - mark entity as tainted and re-process it. */ if (true === $hasAfter && true === $cloned->isWatched() && false === $entity->isWatched()) { - $message = "MAPPER: Watch state conflict detected in '{backend}: {title}' '{new_state}' vs local state '{id}: {current_state}'."; + $message = "DirectMapper: Watch state conflict detected in '{backend}: {title}' '{new_state}' vs local state '{id}: {current_state}'."; $hasMeta = count($cloned->getMetadata($entity->via)) >= 1; $hasDate = $entity->updated === ag($cloned->getMetadata($entity->via), iState::COLUMN_META_DATA_PLAYED_AT); @@ -630,10 +642,10 @@ final class DirectMapper implements iImport $changes = $local->diff(fields: $keys); - $message = "MAPPER: '{backend}' Updated '{title}'."; + $message = "DirectMapper: '{backend}' Updated '{title}'."; if ($cloned->isWatched() !== $local->isWatched()) { - $message = "MAPPER: '{backend}' Updated and marked '{id}: {title}' as '{state}'."; + $message = "DirectMapper: '{backend}' Updated and marked '{id}: {title}' as '{state}'."; if (null !== $onStateUpdate) { $onStateUpdate($local); @@ -664,23 +676,26 @@ final class DirectMapper implements iImport $this->actions[$local->type]['failed']++; Message::increment("{$entity->via}.{$local->type}.failed"); $this->logger->error( - message: "MAPPER: Exception '{error.kind}' was thrown unhandled in '{backend}' '{title}' add. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "DirectMapper: Exception '{error.kind}' was thrown unhandled in '{backend}: {title}' add. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'id' => $cloned->id, + 'backend' => $entity->via, + 'title' => $cloned->getName(), + 'state' => [ + 'database' => $cloned->getAll(), + 'backend' => $entity->getAll() + ], + 'trace' => $e->getTrace(), ], - 'id' => $cloned->id, - 'backend' => $entity->via, - 'title' => $cloned->getName(), - 'state' => [ - 'database' => $cloned->getAll(), - 'backend' => $entity->getAll() - ], - 'trace' => $e->getTrace(), - ] + e: $e + ) ); } @@ -700,7 +715,10 @@ final class DirectMapper implements iImport ]; } - $this->logger->debug("MAPPER: Ignoring '{backend}' '{title}'. Metadata & play state are identical.", $context); + $this->logger->debug( + "DirectMapper: Ignoring '{backend}: {title}'. Metadata & play state are identical.", + $context + ); Message::increment("{$entity->via}.{$entity->type}.ignored_no_change"); diff --git a/src/Libs/Mappers/Import/MemoryMapper.php b/src/Libs/Mappers/Import/MemoryMapper.php index ba3e69d3..358b1024 100644 --- a/src/Libs/Mappers/Import/MemoryMapper.php +++ b/src/Libs/Mappers/Import/MemoryMapper.php @@ -604,7 +604,24 @@ final class MemoryMapper implements iImport } } catch (PDOException $e) { $list[$entity->type]['failed']++; - $this->logger->error($e->getMessage(), $entity->getAll()); + $this->logger->error( + ...lw( + message: "MemoryMapper: Exception '{error.kind}' was thrown unhandled in {mode} '{backend}: {title}'. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'state' => $entity->getAll(), + 'backend' => $entity->via, + 'title' => $entity->getName(), + 'mode' => $entity->id === null ? 'add' : 'update', + ], + e: $e + ) + ); } } diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index e96959c2..3e8901eb 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -12,10 +12,12 @@ use App\Libs\Attributes\Scanner\Item as ScannerItem; use App\Libs\Config; use App\Libs\ConfigFile; use App\Libs\Container; +use App\Libs\Database\DBLayer; use App\Libs\DataUtil; use App\Libs\Entity\StateInterface as iState; use App\Libs\Enums\Http\Status; use App\Libs\Events\DataEvent; +use App\Libs\Exceptions\DBLayerException; use App\Libs\Exceptions\InvalidArgumentException; use App\Libs\Exceptions\RuntimeException; use App\Libs\Extends\Date; @@ -2054,3 +2056,34 @@ if (!function_exists('getBackend')) { return makeBackend(array_replace_recursive($default, $config), $name); } } + +if (!function_exists('lw')) { + /** + * log wrapper. + * + * The use case for this wrapper is to enhance the log context with db exception information. + * All logs should be wrapped with this function. it will probably be enhanced to include further context. + * in the future. + * + * @param string $message The log message. + * @param array $context The log context. + * @param Throwable $e The exception. + * + * @return array{ message: string, context: array} The wrapped log message and context. + */ + function lw(string $message, array $context, Throwable $e): array + { + if (true === ($e instanceof DBLayerException)) { + $context[DBLayer::class] = [ + 'query' => $e->getQueryString(), + 'bind' => $e->getQueryBind(), + 'error' => $e->errorInfo ?? [], + ]; + } + + return [ + 'message' => $message, + 'context' => $context, + ]; + } +} From 5ad57cf69627bcceb5d540b0b905f8657618699b Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A." Date: Thu, 5 Sep 2024 18:51:49 +0300 Subject: [PATCH 3/9] Added app exception interface to allow us to further enhance logging context & messages by allowing us to add arbitrary data. --- src/Libs/Exceptions/AppExceptionInterface.php | 41 +++++++++++++++++++ .../Exceptions/Backends/BackendException.php | 5 ++- src/Libs/Exceptions/DBAdapterException.php | 4 +- src/Libs/Exceptions/DBLayerException.php | 4 +- src/Libs/Exceptions/EmitterException.php | 4 +- src/Libs/Exceptions/ErrorException.php | 3 +- src/Libs/Exceptions/HttpException.php | 3 +- .../Exceptions/InvalidArgumentException.php | 3 +- src/Libs/Exceptions/RuntimeException.php | 3 +- src/Libs/Exceptions/UseAppException.php | 35 ++++++++++++++++ src/Libs/Exceptions/ValidationException.php | 3 +- src/Libs/helpers.php | 16 +++++++- 12 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 src/Libs/Exceptions/AppExceptionInterface.php create mode 100644 src/Libs/Exceptions/UseAppException.php diff --git a/src/Libs/Exceptions/AppExceptionInterface.php b/src/Libs/Exceptions/AppExceptionInterface.php new file mode 100644 index 00000000..634486f7 --- /dev/null +++ b/src/Libs/Exceptions/AppExceptionInterface.php @@ -0,0 +1,41 @@ +appContext = ag_set($this->appContext, $key, $value); + return $this; + } + + public function setContext(array $context): AppExceptionInterface + { + $this->appContext = $context; + + return $this; + } + + public function getContext(string|null $key = null): mixed + { + if (null === $key) { + return $this->appContext; + } + + return ag($this->appContext, $key); + } + + public function hasContext(): bool + { + return !empty($this->appContext); + } +} diff --git a/src/Libs/Exceptions/ValidationException.php b/src/Libs/Exceptions/ValidationException.php index beaccdd6..7cb7f5d3 100644 --- a/src/Libs/Exceptions/ValidationException.php +++ b/src/Libs/Exceptions/ValidationException.php @@ -7,6 +7,7 @@ namespace App\Libs\Exceptions; /** * Class ValidationException */ -class ValidationException extends RuntimeException +class ValidationException extends RuntimeException implements AppExceptionInterface { + use UseAppException; } diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index 3e8901eb..6e4c5a06 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -17,6 +17,7 @@ use App\Libs\DataUtil; use App\Libs\Entity\StateInterface as iState; use App\Libs\Enums\Http\Status; use App\Libs\Events\DataEvent; +use App\Libs\Exceptions\AppExceptionInterface; use App\Libs\Exceptions\DBLayerException; use App\Libs\Exceptions\InvalidArgumentException; use App\Libs\Exceptions\RuntimeException; @@ -2067,12 +2068,19 @@ if (!function_exists('lw')) { * * @param string $message The log message. * @param array $context The log context. - * @param Throwable $e The exception. + * @param Throwable|null $e The exception. * * @return array{ message: string, context: array} The wrapped log message and context. */ - function lw(string $message, array $context, Throwable $e): array + function lw(string $message, array $context, Throwable|null $e = null): array { + if (null === $e) { + return [ + 'message' => $message, + 'context' => $context, + ]; + } + if (true === ($e instanceof DBLayerException)) { $context[DBLayer::class] = [ 'query' => $e->getQueryString(), @@ -2081,6 +2089,10 @@ if (!function_exists('lw')) { ]; } + if (true === ($e instanceof AppExceptionInterface) && $e->hasContext()) { + $context[AppExceptionInterface::class] = $e->getContext(); + } + return [ 'message' => $message, 'context' => $context, From 45ac075023d6b8043378af346873817440b5c947 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A." Date: Thu, 5 Sep 2024 19:00:47 +0300 Subject: [PATCH 4/9] Fixed container startup error. --- config/services.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/config/services.php b/config/services.php index 7db05c73..4e0b69d5 100644 --- a/config/services.php +++ b/config/services.php @@ -199,8 +199,15 @@ return (function (): array { }, ], + DBLayer::class => [ + 'class' => fn(PDO $pdo): DBLayer => new DBLayer($pdo), + 'args' => [ + PDO::class, + ], + ], + iDB::class => [ - 'class' => function (iLogger $logger, PDO $pdo): iDB { + 'class' => function (iLogger $logger, DBLayer $pdo): iDB { $adapter = new PDOAdapter($logger, $pdo); if (true !== $adapter->isMigrated()) { @@ -216,14 +223,7 @@ return (function (): array { }, 'args' => [ iLogger::class, - PDO::class, - ], - ], - - DBLayer::class => [ - 'class' => fn(PDO $pdo): DBLayer => new DBLayer($pdo), - 'args' => [ - PDO::class, + DBLayer::class, ], ], From bd015ae0d4c913f34a4e03b5c7c69a70c8b54c01 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A." Date: Fri, 6 Sep 2024 16:59:36 +0300 Subject: [PATCH 5/9] Updated critical log points to be wrapped in log wrapper for enhanced logs context. --- src/Backends/Common/CommonTrait.php | 3 + src/Backends/Emby/Action/ParseWebhook.php | 3 +- src/Backends/Emby/Action/Progress.php | 82 +-- src/Backends/Jellyfin/Action/Export.php | 55 +- .../Jellyfin/Action/GetLibrariesList.php | 5 +- src/Backends/Jellyfin/Action/Import.php | 527 +++++++++------- src/Backends/Jellyfin/Action/ParseWebhook.php | 3 +- src/Backends/Jellyfin/Action/Progress.php | 46 +- src/Backends/Jellyfin/Action/Push.php | 78 +-- src/Backends/Plex/Action/Export.php | 66 +- src/Backends/Plex/Action/GetLibrariesList.php | 5 +- src/Backends/Plex/Action/GetUserToken.php | 3 +- src/Backends/Plex/Action/Import.php | 594 ++++++++++-------- src/Backends/Plex/Action/Progress.php | 82 +-- src/Backends/Plex/Action/Push.php | 78 +-- src/Libs/Database/DBLayer.php | 6 +- src/Libs/Database/PDO/PDOAdapter.php | 4 +- 17 files changed, 896 insertions(+), 744 deletions(-) diff --git a/src/Backends/Common/CommonTrait.php b/src/Backends/Common/CommonTrait.php index a6b5c348..08f97924 100644 --- a/src/Backends/Common/CommonTrait.php +++ b/src/Backends/Common/CommonTrait.php @@ -36,6 +36,7 @@ trait CommonTrait return new Response( status: false, error: new Error( + ...lw( message: "{client}: '{backend}' {action} thrown unhandled exception '{error.kind}'. '{error.message}' at '{error.file}:{error.line}'.", context: [ 'action' => $action ?? '', @@ -56,6 +57,8 @@ trait CommonTrait 'trace' => $e->getTrace(), ] ], + e: $e + ), level: Levels::WARNING, previous: $e ) diff --git a/src/Backends/Emby/Action/ParseWebhook.php b/src/Backends/Emby/Action/ParseWebhook.php index c9617c45..37f7bd93 100644 --- a/src/Backends/Emby/Action/ParseWebhook.php +++ b/src/Backends/Emby/Action/ParseWebhook.php @@ -307,7 +307,8 @@ final class ParseWebhook ], 'trace' => $e->getTrace(), ], - level: Levels::ERROR + level: Levels::ERROR, + previous: $e ), extra: [ 'http_code' => 200, diff --git a/src/Backends/Emby/Action/Progress.php b/src/Backends/Emby/Action/Progress.php index fc9ba14d..4cea1f8c 100644 --- a/src/Backends/Emby/Action/Progress.php +++ b/src/Backends/Emby/Action/Progress.php @@ -226,26 +226,29 @@ class Progress } } catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) { $this->logger->error( - message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.", - contexT: [ - 'action' => $this->action, - 'client' => $context->clientName, - 'backend' => $context->backendName, - ...$logContext, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'action' => $this->action, + 'client' => $context->clientName, + 'backend' => $context->backendName, + ...$logContext, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); continue; } @@ -293,26 +296,29 @@ class Progress } } catch (Throwable $e) { $this->logger->error( - message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'action' => $this->action, - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'action' => $this->action, + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - ...$logContext, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); } } diff --git a/src/Backends/Jellyfin/Action/Export.php b/src/Backends/Jellyfin/Action/Export.php index 62bbe2ef..cc3b54e8 100644 --- a/src/Backends/Jellyfin/Action/Export.php +++ b/src/Backends/Jellyfin/Action/Export.php @@ -91,11 +91,17 @@ class Export extends Import 'type' => $type, ]; } catch (InvalidArgumentException $e) { - $this->logger->info($e->getMessage(), [ - 'backend' => $context->backendName, - ...$logContext, - 'body' => $item, - ]); + $this->logger->info( + ...lw( + message: $e->getMessage(), + context: [ + 'backend' => $context->backendName, + ...$logContext, + 'body' => $item, + ], + e: $e + ) + ); return; } @@ -257,25 +263,28 @@ class Export extends Import ); } catch (Throwable $e) { $this->logger->error( - message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] export. Error [{error.message} @ {error.file}:{error.line}].', - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] export. Error [{error.message} @ {error.file}:{error.line}].', + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - ...$logContext, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); } } diff --git a/src/Backends/Jellyfin/Action/GetLibrariesList.php b/src/Backends/Jellyfin/Action/GetLibrariesList.php index 8629cf0d..d1ef5fe8 100644 --- a/src/Backends/Jellyfin/Action/GetLibrariesList.php +++ b/src/Backends/Jellyfin/Action/GetLibrariesList.php @@ -77,7 +77,10 @@ class GetLibrariesList $this->logger ); } catch (RuntimeException $e) { - return new Response(status: false, error: new Error(message: $e->getMessage(), level: Levels::ERROR)); + return new Response( + status: false, + error: new Error(message: $e->getMessage(), level: Levels::ERROR, previous: $e) + ); } if ($context->trace) { diff --git a/src/Backends/Jellyfin/Action/Import.php b/src/Backends/Jellyfin/Action/Import.php index 99b6b915..6c197e9b 100644 --- a/src/Backends/Jellyfin/Action/Import.php +++ b/src/Backends/Jellyfin/Action/Import.php @@ -192,69 +192,78 @@ class Import } } catch (ExceptionInterface $e) { $this->logger->error( - message: "Request for '{client}: {backend}' libraries has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'line' => $e->getLine(), - 'kind' => $e::class, - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Request for '{client}: {backend}' libraries has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'line' => $e->getLine(), + 'kind' => $e::class, + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => $e::class, + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => $e::class, - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); Message::add("{$context->backendName}.has_errors", true); return []; } catch (JsonException $e) { $this->logger->error( - message: "Request for '{client}: {backend}' libraries returned with invalid body. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'line' => $e->getLine(), - 'kind' => $e::class, - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Request for '{client}: {backend}' libraries returned with invalid body. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'line' => $e->getLine(), + 'kind' => $e::class, + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); Message::add("{$context->backendName}.has_errors", true); return []; } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); Message::add("{$context->backendName}.has_errors", true); return []; @@ -319,48 +328,54 @@ class Import ); } catch (ExceptionInterface $e) { $this->logger->error( - message: "Request for '{client}: {backend}' '{library.title}' items count failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'line' => $e->getLine(), - 'kind' => $e::class, - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Request for '{client}: {backend}' '{library.title}' items count failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'line' => $e->getLine(), + 'kind' => $e::class, + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], + ...$logContext, ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ...$logContext, - ] + e: $e + ) ); continue; } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' items count request. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' items count request. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], + ...$logContext, ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ...$logContext, - ] + e: $e + ) ); continue; } @@ -404,6 +419,7 @@ class Import $total[ag($logContext, 'library.id')] = $totalCount; } catch (ExceptionInterface $e) { $this->logger->error( + ...lw( message: "Request for '{client}: {backend}' '{library.title}' total items has failed. '{error.kind}' '{error.message}' at '{error.file}:{error.line}'.", context: [ 'client' => $context->backendName, @@ -422,31 +438,35 @@ class Import 'trace' => $e->getTrace(), ], ...$logContext, - - ] + ], + e: $e + ), ); continue; } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' requests for items count. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' requests for items count. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], + ...$logContext, ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ...$logContext, - ] + e: $e + ) ); continue; } @@ -515,29 +535,33 @@ class Import ); } catch (ExceptionInterface $e) { $this->logger->error( - message: "Request for '{client}: {backend}' '{library.title}' series external ids has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'line' => $e->getLine(), - 'kind' => $e::class, - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Request for '{client}: {backend}' '{library.title}' series external ids has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'line' => $e->getLine(), + 'kind' => $e::class, + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], + ...$logContext, ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ...$logContext, - ] + e: $e + ) ); continue; } catch (Throwable $e) { $this->logger->error( + ...lw( message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' series external ids request. '{error.message}' at '{error.file}:{error.line}'.", context: [ 'backend' => $context->backendName, @@ -556,7 +580,9 @@ class Import 'trace' => $e->getTrace(), ], ...$logContext, - ] + ], + e: $e + ), ); continue; } @@ -663,48 +689,54 @@ class Import ); } catch (ExceptionInterface $e) { $this->logger->error( - "Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list has failed. {error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", - [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'line' => $e->getLine(), - 'kind' => $e::class, - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list has failed. {error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'line' => $e->getLine(), + 'kind' => $e::class, + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - ...$logContext, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); continue; } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list request. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list request. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - ...$logContext, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); continue; } @@ -850,50 +882,56 @@ class Import } } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing '{library.title} {segment.number}/{segment.of}' item response. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing '{library.title} {segment.number}/{segment.of}' item response. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'entity' => $entity, + 'exception' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'trace' => $e->getTrace(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, ], - 'entity' => $entity, - 'exception' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'trace' => $e->getTrace(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), - ], - ...$logContext, - ] + e: $e + ) ); } } } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing of '{library.title} {segment.number}/{segment.of}' response. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing of '{library.title} {segment.number}/{segment.of}' response. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, ], - 'exception' => [ - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - 'file' => after($e->getFile(), ROOT_PATH), - ], - ...$logContext, - ] + e: $e + ) ); } @@ -1035,19 +1073,22 @@ class Import ]; } catch (InvalidArgumentException $e) { $this->logger->error( - "Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ", - [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'body' => $item, + ...$logContext, ], - 'body' => $item, - ...$logContext, - ] + e: $e + ) ); return; } @@ -1110,18 +1151,21 @@ class Import ); } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' occurred during '{client}: {backend}' '{library.title}' '{item.id}: {item.title}' entity creation. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' occurred during '{client}: {backend}' '{library.title}' '{item.id}: {item.title}' entity creation. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, ], - ...$logContext, - ] + e: $e + ) ); Message::increment("{$context->backendName}.{$mappedType}.ignored_no_date_is_set"); @@ -1155,26 +1199,29 @@ class Import $mapper->add(entity: $entity, opts: $opts); } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' '{item.title}' {action}. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'action' => property_exists($this, 'action') ? $this->action : 'import', - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' '{item.title}' {action}. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'action' => property_exists($this, 'action') ? $this->action : 'import', + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], + ...$logContext, ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ...$logContext, - ] + e: $e + ) ); } } diff --git a/src/Backends/Jellyfin/Action/ParseWebhook.php b/src/Backends/Jellyfin/Action/ParseWebhook.php index a8637fcd..e04d4282 100644 --- a/src/Backends/Jellyfin/Action/ParseWebhook.php +++ b/src/Backends/Jellyfin/Action/ParseWebhook.php @@ -265,7 +265,8 @@ final class ParseWebhook 'payload' => $request->getParsedBody(), ], ], - level: Levels::ERROR + level: Levels::ERROR, + previous: $e ), extra: [ 'http_code' => 200, diff --git a/src/Backends/Jellyfin/Action/Progress.php b/src/Backends/Jellyfin/Action/Progress.php index c1aa9c2d..09549fb7 100644 --- a/src/Backends/Jellyfin/Action/Progress.php +++ b/src/Backends/Jellyfin/Action/Progress.php @@ -254,6 +254,7 @@ class Progress } } catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) { $this->logger->error( + ...lw( message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.", context: [ 'action' => $this->action, @@ -273,7 +274,9 @@ class Progress 'message' => $e->getMessage(), 'trace' => $e->getTrace(), ], - ] + ], + e: $e + ), ); continue; } @@ -321,26 +324,29 @@ class Progress } } catch (Throwable $e) { $this->logger->error( - message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'action' => $this->action, - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'action' => $this->action, + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - ...$logContext, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); } } diff --git a/src/Backends/Jellyfin/Action/Push.php b/src/Backends/Jellyfin/Action/Push.php index 745ad177..c3a9295b 100644 --- a/src/Backends/Jellyfin/Action/Push.php +++ b/src/Backends/Jellyfin/Action/Push.php @@ -145,25 +145,28 @@ class Push ); } catch (Throwable $e) { $this->logger->error( - message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] request for {item.type} [{item.title}] metadata. Error [{error.message} @ {error.file}:{error.line}].', - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] request for {item.type} [{item.title}] metadata. Error [{error.message} @ {error.file}:{error.line}].', + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - ...$logContext, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); } } @@ -327,25 +330,28 @@ class Push } } catch (Throwable $e) { $this->logger->error( - message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] parsing [{library.title}] [{segment.number}/{segment.of}] response. Error [{error.message} @ {error.file}:{error.line}].', - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] parsing [{library.title}] [{segment.number}/{segment.of}] response. Error [{error.message} @ {error.file}:{error.line}].', + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - ...$logContext, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); } } diff --git a/src/Backends/Plex/Action/Export.php b/src/Backends/Plex/Action/Export.php index 6eff7215..4b5cb5cb 100644 --- a/src/Backends/Plex/Action/Export.php +++ b/src/Backends/Plex/Action/Export.php @@ -76,19 +76,22 @@ final class Export extends Import ]; } catch (InvalidArgumentException $e) { $this->logger->error( - "Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ", - [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'body' => $item, ], - ...$logContext, - 'body' => $item, - ] + e: $e + ) ); return; } @@ -242,25 +245,28 @@ final class Export extends Import ]))); } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' export. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' export. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - ...$logContext, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); } } diff --git a/src/Backends/Plex/Action/GetLibrariesList.php b/src/Backends/Plex/Action/GetLibrariesList.php index 85b833a1..64caf922 100644 --- a/src/Backends/Plex/Action/GetLibrariesList.php +++ b/src/Backends/Plex/Action/GetLibrariesList.php @@ -66,7 +66,10 @@ final class GetLibrariesList $this->logger ); } catch (RuntimeException $e) { - return new Response(status: false, error: new Error(message: $e->getMessage(), level: Levels::ERROR)); + return new Response( + status: false, + error: new Error(message: $e->getMessage(), level: Levels::ERROR, previous: $e) + ); } if ($context->trace) { diff --git a/src/Backends/Plex/Action/GetUserToken.php b/src/Backends/Plex/Action/GetUserToken.php index 63ffd5c0..324e878d 100644 --- a/src/Backends/Plex/Action/GetUserToken.php +++ b/src/Backends/Plex/Action/GetUserToken.php @@ -246,7 +246,8 @@ final class GetUserToken 'trace' => $e->getTrace(), ], ], - level: Levels::ERROR + level: Levels::ERROR, + previous: $e ), ); } diff --git a/src/Backends/Plex/Action/Import.php b/src/Backends/Plex/Action/Import.php index d30c700f..db70a3b4 100644 --- a/src/Backends/Plex/Action/Import.php +++ b/src/Backends/Plex/Action/Import.php @@ -173,69 +173,78 @@ class Import } } catch (ExceptionInterface $e) { $this->logger->error( - message: "Request for '{client}: {backend}' libraries has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'line' => $e->getLine(), - 'kind' => $e::class, - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Request for '{client}: {backend}' libraries has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'line' => $e->getLine(), + 'kind' => $e::class, + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => $e::class, + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => $e::class, - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); Message::add("{$context->backendName}.has_errors", true); return []; } catch (JsonException $e) { $this->logger->error( - message: "Request for '{client}: {backend}' libraries returned with invalid body. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'line' => $e->getLine(), - 'kind' => $e::class, - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Request for '{client}: {backend}' libraries returned with invalid body. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'line' => $e->getLine(), + 'kind' => $e::class, + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); Message::add("{$context->backendName}.has_errors", true); return []; } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); Message::add("{$context->backendName}.has_errors", true); return []; @@ -351,48 +360,54 @@ class Import } } catch (ExceptionInterface $e) { $this->logger->error( - message: "Request for '{client}: {backend}' '{library.title}' items count has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'line' => $e->getLine(), - 'kind' => $e::class, - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Request for '{client}: {backend}' '{library.title}' items count has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'line' => $e->getLine(), + 'kind' => $e::class, + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], + ...$logContext, ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ...$logContext, - ] + e: $e + ) ); continue; } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], + ...$logContext, ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ...$logContext, - ] + e: $e + ) ); continue; } @@ -438,48 +453,54 @@ class Import } } catch (ExceptionInterface $e) { $this->logger->error( - message: "Request for '{client}: {backend}' '{library.title}' total items has failed. '{error.kind}' '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'client' => $context->backendName, - 'backend' => $context->backendName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Request for '{client}: {backend}' '{library.title}' total items has failed. '{error.kind}' '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'client' => $context->backendName, + 'backend' => $context->backendName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], + ...$logContext, ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ...$logContext, - ] + e: $e + ) ); continue; } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for items count. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for items count. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], + ...$logContext, ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ...$logContext, - ] + e: $e + ) ); continue; } @@ -576,48 +597,54 @@ class Import ); } catch (ExceptionInterface $e) { $this->logger->error( - message: "Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' series external ids has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'line' => $e->getLine(), - 'kind' => $e::class, - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' series external ids has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'line' => $e->getLine(), + 'kind' => $e::class, + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], + ...$logContext, ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ...$logContext, - ] + e: $e + ) ); continue; } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' series external ids request. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' series external ids request. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - ...$logContext, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); continue; } @@ -732,48 +759,54 @@ class Import ); } catch (ExceptionInterface $e) { $this->logger->error( - "Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list has failed. {error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", - [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'line' => $e->getLine(), - 'kind' => $e::class, - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list has failed. {error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'line' => $e->getLine(), + 'kind' => $e::class, + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - ...$logContext, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); continue; } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list request. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list request. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - ...$logContext, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); continue; } @@ -863,50 +896,56 @@ class Import $callback(item: $entity, logContext: $logContext); } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing '{library.title} {segment.number}/{segment.of}' item response. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing '{library.title} {segment.number}/{segment.of}' item response. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'entity' => $entity, + 'exception' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'trace' => $e->getTrace(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, ], - 'entity' => $entity, - 'exception' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'trace' => $e->getTrace(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), - ], - ...$logContext, - ] + e: $e + ) ); } } } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing of '{library.title} {segment.number}/{segment.of}' response. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing of '{library.title} {segment.number}/{segment.of}' response. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, ], - 'exception' => [ - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - 'file' => after($e->getFile(), ROOT_PATH), - ], - ...$logContext, - ] + e: $e + ) ); } @@ -1051,19 +1090,22 @@ class Import ]; } catch (InvalidArgumentException $e) { $this->logger->error( - "Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ", - [ - 'client' => $context->clientName, - 'backend' => $context->backendName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ", + context: [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'body' => $item, ], - ...$logContext, - 'body' => $item, - ] + e: $e + ) ); return; } @@ -1103,18 +1145,21 @@ class Import ); } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' occurred during '{client}: {backend}' '{library.title}' '{item.id}: {item.title}' entity creation. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' occurred during '{client}: {backend}' '{library.title}' '{item.id}: {item.title}' entity creation. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, ], - ...$logContext, - ] + e: $e + ) ); return; } @@ -1151,26 +1196,29 @@ class Import ]); } catch (Throwable $e) { $this->logger->error( - message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' '{item.title}' {action}. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'action' => property_exists($this, 'action') ? $this->action : 'import', - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' '{item.title}' {action}. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'action' => property_exists($this, 'action') ? $this->action : 'import', + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], + ...$logContext, ], - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ...$logContext, - ] + e: $e + ) ); } } diff --git a/src/Backends/Plex/Action/Progress.php b/src/Backends/Plex/Action/Progress.php index c193da4d..b4b383f5 100644 --- a/src/Backends/Plex/Action/Progress.php +++ b/src/Backends/Plex/Action/Progress.php @@ -230,26 +230,29 @@ class Progress } } catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) { $this->logger->error( - message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'action' => $this->action, - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'action' => $this->action, + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - ...$logContext, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); continue; } @@ -305,26 +308,29 @@ class Progress } } catch (Throwable $e) { $this->logger->error( - message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.", - context: [ - 'action' => $this->action, - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.", + context: [ + 'action' => $this->action, + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - ...$logContext, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); } } diff --git a/src/Backends/Plex/Action/Push.php b/src/Backends/Plex/Action/Push.php index e6f24bbe..42757983 100644 --- a/src/Backends/Plex/Action/Push.php +++ b/src/Backends/Plex/Action/Push.php @@ -115,25 +115,28 @@ final class Push ); } catch (Throwable $e) { $this->logger->error( - message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] request for {item.type} [{item.title}] metadata. Error [{error.message} @ {error.file}:{error.line}].', - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] request for {item.type} [{item.title}] metadata. Error [{error.message} @ {error.file}:{error.line}].', + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'exception' => [ + 'file' => after($e->getFile(), ROOT_PATH), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - ...$logContext, - 'exception' => [ - 'file' => after($e->getFile(), ROOT_PATH), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); } } @@ -308,25 +311,28 @@ final class Push } } catch (Throwable $e) { $this->logger->error( - message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] push. Error [{error.message} @ {error.file}:{error.line}].', - context: [ - 'backend' => $context->backendName, - 'client' => $context->clientName, - 'error' => [ - 'kind' => $e::class, - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - 'file' => after($e->getFile(), ROOT_PATH), + ...lw( + message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] push. Error [{error.message} @ {error.file}:{error.line}].', + context: [ + 'backend' => $context->backendName, + 'client' => $context->clientName, + 'error' => [ + 'kind' => $e::class, + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'file' => after($e->getFile(), ROOT_PATH), + ], + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTrace(), + ], ], - ...$logContext, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - 'trace' => $e->getTrace(), - ], - ] + e: $e + ) ); } } diff --git a/src/Libs/Database/DBLayer.php b/src/Libs/Database/DBLayer.php index ddc4ecb1..85d5f3ba 100644 --- a/src/Libs/Database/DBLayer.php +++ b/src/Libs/Database/DBLayer.php @@ -119,7 +119,7 @@ final class DBLayer implements LoggerAwareInterface if ($e instanceof DBLayerException) { throw $e; } - throw (new DBLayerException($e->getMessage(), $e->getCode(), $e)) + throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e)) ->setInfo( (true === ($sql instanceof PDOStatement)) ? $sql->queryString : $sql, $bind, @@ -791,7 +791,7 @@ final class DBLayer implements LoggerAwareInterface } catch (PDOException $e) { if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) { if ($i >= self::LOCK_RETRY) { - throw (new DBLayerException($e->getMessage(), $e->getCode(), $e)) + throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e)) ->setInfo($stnt->queryString, $bind, $e->errorInfo ?? [], $e->getCode()) ->setFile($e->getFile()) ->setLine($e->getLine()); @@ -805,7 +805,7 @@ final class DBLayer implements LoggerAwareInterface sleep($sleep); } else { - throw (new DBLayerException($e->getMessage(), $e->getCode(), $e)) + throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e)) ->setInfo($stnt->queryString, $bind, $e->errorInfo ?? [], $e->getCode()) ->setFile($e->getFile()) ->setLine($e->getLine()); diff --git a/src/Libs/Database/PDO/PDOAdapter.php b/src/Libs/Database/PDO/PDOAdapter.php index e3895f56..f051c28e 100644 --- a/src/Libs/Database/PDO/PDOAdapter.php +++ b/src/Libs/Database/PDO/PDOAdapter.php @@ -939,7 +939,7 @@ final class PDOAdapter implements iDB } catch (PDOException $e) { if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) { if ($i >= self::LOCK_RETRY) { - throw (new DBLayerException($e->getMessage(), $e->getCode(), $e)) + throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e)) ->setInfo( ag($this->db->getLastStatement(), 'sql', ''), ag($this->db->getLastStatement(), 'bind', []), @@ -958,7 +958,7 @@ final class PDOAdapter implements iDB sleep($sleep); } else { - throw (new DBLayerException($e->getMessage(), $e->getCode(), $e)) + throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e)) ->setInfo( ag($this->db->getLastStatement(), 'sql', ''), ag($this->db->getLastStatement(), 'bind', []), From 07987d9bbd835f4552c7f8532412f17fb62e0749 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A." Date: Fri, 6 Sep 2024 17:06:10 +0300 Subject: [PATCH 6/9] Updated test to reflect that we added reference to exceptions in the majority of critical points. --- src/Backends/Plex/Action/GetLibrariesList.php | 17 +++++++---------- tests/Backends/Plex/GetLibrariesListTest.php | 6 +++--- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/Backends/Plex/Action/GetLibrariesList.php b/src/Backends/Plex/Action/GetLibrariesList.php index 64caf922..4f6c77eb 100644 --- a/src/Backends/Plex/Action/GetLibrariesList.php +++ b/src/Backends/Plex/Action/GetLibrariesList.php @@ -85,7 +85,7 @@ final class GetLibrariesList return new Response( status: false, error: new Error( - message: 'Request for [{backend}] libraries returned empty list.', + message: "Request for '{backend}' libraries returned empty list.", context: [ 'backend' => $context->backendName, 'response' => [ @@ -156,7 +156,7 @@ final class GetLibrariesList { $url = $context->backendUrl->withPath('/library/sections'); - $this->logger->debug('Requesting [{backend}] libraries list.', [ + $this->logger->debug("Requesting '{backend}' libraries list.", [ 'backend' => $context->backendName, 'url' => (string)$url ]); @@ -166,7 +166,7 @@ final class GetLibrariesList $payload = $response->getContent(false); if ($context->trace) { - $this->logger->debug('Processing [{backend}] response.', [ + $this->logger->debug("Processing '{backend}' response.", [ 'backend' => $context->backendName, 'url' => (string)$url, 'response' => $payload, @@ -175,13 +175,10 @@ final class GetLibrariesList if (200 !== $response->getStatusCode()) { throw new RuntimeException( - r( - 'Request for [{backend}] libraries returned with unexpected [{status_code}] status code.', - [ - 'backend' => $context->backendName, - 'status_code' => $response->getStatusCode(), - ] - ) + r("Request for '{backend}' libraries returned with unexpected '{status_code}' status code.", [ + 'backend' => $context->backendName, + 'status_code' => $response->getStatusCode(), + ]) ); } diff --git a/tests/Backends/Plex/GetLibrariesListTest.php b/tests/Backends/Plex/GetLibrariesListTest.php index 558e7f7e..7f9c54b6 100644 --- a/tests/Backends/Plex/GetLibrariesListTest.php +++ b/tests/Backends/Plex/GetLibrariesListTest.php @@ -118,12 +118,12 @@ class GetLibrariesListTest extends TestCase $this->assertFalse($response->status); $this->assertNotNull($response->error); - $this->assertSame( - 'ERROR: Request for [Plex] libraries returned with unexpected [401] status code.', + $this->assertStringContainsString( + "ERROR: Request for 'Plex' libraries returned with unexpected '401' status code.", (string)$response->error ); $this->assertNull($response->response); - $this->assertFalse($response->error->hasException()); + $this->assertTrue($response->error->hasException()); } } From fa410d6d6773509d2a7c3fa1ac1a41e6824052e0 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A." Date: Fri, 6 Sep 2024 20:17:48 +0300 Subject: [PATCH 7/9] Updated helpers tests. --- src/Libs/Guid.php | 30 +- src/Libs/helpers.php | 29 +- tests/Fixtures/meminfo_data.txt | 55 ++++ tests/Libs/HelpersTest.php | 523 +++++++++++++++++++++++++++++++- 4 files changed, 602 insertions(+), 35 deletions(-) create mode 100644 tests/Fixtures/meminfo_data.txt diff --git a/src/Libs/Guid.php b/src/Libs/Guid.php index 368fe320..4ce6e572 100644 --- a/src/Libs/Guid.php +++ b/src/Libs/Guid.php @@ -114,7 +114,7 @@ final class Guid implements JsonSerializable, Stringable if (false === is_string($key)) { $this->getLogger()->info( - 'Ignoring [{backend}] {item.type} [{item.title}] external id. Unexpected key type [{given}] was given.', + "Ignoring '{backend}' {item.type} '{item.title}' external id. Unexpected key type '{given}' was given.", [ 'key' => (string)$key, 'given' => get_debug_type($key), @@ -126,7 +126,7 @@ final class Guid implements JsonSerializable, Stringable if (null === (self::SUPPORTED[$key] ?? null)) { $this->getLogger()->info( - 'Ignoring [{backend}] {item.type} [{item.title}] [{key}] external id. Not supported.', + "Ignoring '{backend}' {item.type} '{item.title}' '{key}' external id. Not supported.", [ 'key' => $key, ...$context, @@ -137,7 +137,7 @@ final class Guid implements JsonSerializable, Stringable if (self::SUPPORTED[$key] !== ($valueType = get_debug_type($value))) { $this->getLogger()->info( - 'Ignoring [{backend}] {item.type} [{item.title}] [{key}] external id. Unexpected value type.', + "Ignoring '{backend}' {item.type} '{item.title}' '{key}' external id. Unexpected value type.", [ 'key' => $key, 'condition' => [ @@ -153,7 +153,7 @@ final class Guid implements JsonSerializable, Stringable if (null !== (self::VALIDATE_GUID[$key] ?? null)) { if (1 !== preg_match(self::VALIDATE_GUID[$key]['pattern'], $value)) { $this->getLogger()->info( - 'Ignoring [{backend}] {item.type} [{item.title}] [{key}] external id. Unexpected [{given}] value, expecting [{expected}].', + "Ignoring '{backend}' {item.type} '{item.title}' '{key}' external id. Unexpected value '{given}'. Expecting '{expected}'.", [ 'key' => $key, 'expected' => self::VALIDATE_GUID[$key]['example'], @@ -229,12 +229,10 @@ final class Guid implements JsonSerializable, Stringable $lookup = 'guid_' . $db; if (false === array_key_exists($lookup, self::SUPPORTED)) { - throw new InvalidArgumentException( - r('Invalid db [{db}] source was given. Expecting [{db_list}].', [ - 'db' => $db, - 'db_list' => implode(', ', array_map(fn($f) => after($f, 'guid_'), array_keys(self::SUPPORTED))), - ]) - ); + throw new InvalidArgumentException(r("Invalid db '{db}' source was given. Expecting '{db_list}'.", [ + 'db' => $db, + 'db_list' => implode(', ', array_map(fn($f) => after($f, 'guid_'), array_keys(self::SUPPORTED))), + ])); } if (null === (self::VALIDATE_GUID[$lookup] ?? null)) { @@ -242,13 +240,11 @@ final class Guid implements JsonSerializable, Stringable } if (1 !== @preg_match(self::VALIDATE_GUID[$lookup]['pattern'], $id)) { - throw new InvalidArgumentException( - r('Invalid [{value}] value for [{db}]. Expecting [{example}].', [ - 'db' => $db, - 'value' => $id, - 'example' => self::VALIDATE_GUID[$lookup]['example'], - ]) - ); + throw new InvalidArgumentException(r("Invalid value '{value}' for '{db}' GUID. Expecting '{example}'.", [ + 'db' => $db, + 'value' => $id, + 'example' => self::VALIDATE_GUID[$lookup]['example'], + ])); } return true; diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index 6e4c5a06..47ada885 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -259,9 +259,8 @@ if (!function_exists('ag_delete')) { } if (is_int($path)) { - if (isset($array[$path])) { - unset($array[$path]); - } + // -- if the path is int, and it's exists, it should have been caught by + // -- the first if condition. So, we can safely return the array as is. return $array; } @@ -969,21 +968,13 @@ if (false === function_exists('r_array')) { $pattern = '#' . preg_quote($tagLeft, '#') . '([\w_.]+)' . preg_quote($tagRight, '#') . '#is'; - $status = preg_match_all($pattern, $text, $matches); - - if (false === $status || $status < 1) { - return ['message' => $text, 'context' => $context]; - } + preg_match_all($pattern, $text, $matches); $replacements = []; foreach ($matches[1] as $key) { $placeholder = $tagLeft . $key . $tagRight; - if (false === str_contains($text, $placeholder)) { - continue; - } - if (false === ag_exists($context, $key)) { continue; } @@ -1028,7 +1019,7 @@ if (false === function_exists('generateRoutes')) { * * @return array The generated routes. */ - function generateRoutes(string $type = 'cli'): array + function generateRoutes(string $type = 'cli', array $opts = []): array { $dirs = [__DIR__ . '/../Commands']; foreach (array_keys(Config::get('supported', [])) as $backend) { @@ -1043,7 +1034,7 @@ if (false === function_exists('generateRoutes')) { $routes_cli = (new Router($dirs))->generate(); - $cache = Container::get(iCache::class); + $cache = $opts[iCache::class] ?? Container::get(iCache::class); try { $cache->set('routes_cli', $routes_cli, new DateInterval('PT1H')); @@ -1147,7 +1138,7 @@ if (false === function_exists('getSystemMemoryInfo')) { * * @return array{ MemTotal: float, MemFree: float, MemAvailable: float, SwapTotal: float, SwapFree: float } */ - function getSystemMemoryInfo(): array + function getSystemMemoryInfo(string $memFile = '/proc/meminfo'): array { $keys = [ 'MemTotal' => 'mem_total', @@ -1159,11 +1150,11 @@ if (false === function_exists('getSystemMemoryInfo')) { $result = []; - if (!is_readable('/proc/meminfo')) { + if (!is_readable($memFile)) { return $result; } - if (false === ($lines = @file('/proc/meminfo', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES))) { + if (false === ($lines = @file($memFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES))) { return $result; } @@ -1215,6 +1206,10 @@ if (!function_exists('checkIgnoreRule')) { { $urlParts = parse_url($guid); + if (false === is_array($urlParts)) { + throw new RuntimeException('Invalid ignore rule was given.'); + } + if (null === ($db = ag($urlParts, 'user'))) { throw new RuntimeException('No db source was given.'); } diff --git a/tests/Fixtures/meminfo_data.txt b/tests/Fixtures/meminfo_data.txt new file mode 100644 index 00000000..448fd0a5 --- /dev/null +++ b/tests/Fixtures/meminfo_data.txt @@ -0,0 +1,55 @@ +MemTotal: 131598708 kB +MemFree: 10636272 kB +MemAvailable: 113059644 kB +Buffers: 1851320 kB +Cached: 96874768 kB +SwapCached: 199724 kB +Active: 15252072 kB +Inactive: 96757496 kB +Active(anon): 4046452 kB +Inactive(anon): 11604056 kB +Active(file): 11205620 kB +Inactive(file): 85153440 kB +Unevictable: 43880 kB +Mlocked: 0 kB +SwapTotal: 144758584 kB +SwapFree: 140512824 kB +Zswap: 0 kB +Zswapped: 0 kB +Dirty: 28 kB +Writeback: 0 kB +AnonPages: 12869660 kB +Mapped: 760892 kB +Shmem: 2367028 kB +KReclaimable: 7315620 kB +Slab: 8107296 kB +SReclaimable: 7315620 kB +SUnreclaim: 791676 kB +KernelStack: 42512 kB +PageTables: 78184 kB +SecPageTables: 0 kB +NFS_Unstable: 0 kB +Bounce: 0 kB +WritebackTmp: 0 kB +CommitLimit: 210557936 kB +Committed_AS: 38290840 kB +VmallocTotal: 34359738367 kB +VmallocUsed: 198508 kB +VmallocChunk: 0 kB +Percpu: 37760 kB +HardwareCorrupted: 0 kB +AnonHugePages: 5797888 kB +ShmemHugePages: 47104 kB +ShmemPmdMapped: 0 kB +FileHugePages: 0 kB +FilePmdMapped: 0 kB +HugePages_Total: 0 +HugePages_Free: 0 +HugePages_Rsvd: 0 +HugePages_Surp: 0 +Hugepagesize: 2048 kB +Hugetlb: 0 kB +DirectMap4k: 11644304 kB +DirectMap2M: 119123968 kB +DirectMap1G: 3145728 kB + diff --git a/tests/Libs/HelpersTest.php b/tests/Libs/HelpersTest.php index f8517802..cb5b423b 100644 --- a/tests/Libs/HelpersTest.php +++ b/tests/Libs/HelpersTest.php @@ -4,8 +4,10 @@ declare(strict_types=1); namespace Tests\Libs; +use App\Backends\Plex\PlexClient; use App\Libs\Config; use App\Libs\Entity\StateEntity; +use App\Libs\Enums\Http\Method; use App\Libs\Enums\Http\Status; use App\Libs\Exceptions\InvalidArgumentException; use App\Libs\Exceptions\RuntimeException; @@ -13,11 +15,15 @@ use App\Libs\TestCase; use JsonMachine\Items; use JsonMachine\JsonDecoder\ErrorWrappingDecoder; use JsonMachine\JsonDecoder\ExtJsonDecoder; +use JsonSerializable; use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Stream; use Nyholm\Psr7Server\ServerRequestCreator; +use Psr\SimpleCache\CacheInterface; +use Stringable; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; +use TypeError; class HelpersTest extends TestCase { @@ -171,6 +177,32 @@ class HelpersTest extends TestCase ag_set([], 'foo.kaz', 'taz'), 'When a nested key is passed, it will be saved in format of [key => [nested_key => value]]' ); + + $exception = null; + try { + ag_set(['foo' => 'bar'], 'foo.bar.taz', 'baz'); + } catch (\Throwable $e) { + $exception = $e; + } finally { + $this->assertSame( + TypeError::class, + $exception ? $exception::class : null, + 'When trying to set value to non-array, exception is thrown.' + ); + } + + $exception = null; + try { + ag_set(['foo' => ['bar' => ['taz' => 'tt']]], 'foo.bar.taz.tt', 'baz'); + } catch (\Throwable $e) { + $exception = $e; + } finally { + $this->assertSame( + RuntimeException::class, + $exception ? $exception::class : null, + 'When trying to set value to existing key, exception is thrown.' + ); + } } public function test_ag_exits(): void @@ -221,6 +253,23 @@ class HelpersTest extends TestCase ag_delete($arr, 'foo'), 'When simple key is passed, and it exists, it is deleted, and copy of the modified array is returned' ); + $this->assertSame( + [0 => 'foo', 1 => 'bar'], + ag_delete([0 => 'foo', 1 => 'bar', 2 => 'taz'], 2), + 'When an int key is passed, and it exists, it is deleted, and copy of the modified array is returned' + ); + + $this->assertSame( + $arr, + ag_delete($arr, 121), + 'When an int key is passed, and it does not exist, original array is returned' + ); + + $this->assertSame( + $arr, + ag_delete($arr, 'test.bar'), + 'When a non-existing key is passed, original array is returned.' + ); } public function test_fixPath(): void @@ -332,6 +381,7 @@ class HelpersTest extends TestCase ] ]); $data = ['foo' => 'bar']; + api_response(200, $data); $response = api_response(Status::OK, $data); $this->assertSame(Status::OK->value, $response->getStatusCode()); $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); @@ -354,11 +404,50 @@ class HelpersTest extends TestCase ]); $data = ['error' => ['code' => Status::BAD_REQUEST->value, 'message' => 'error message']]; - $response = api_error('error message', Status::BAD_REQUEST); + $response = api_error('error message', Status::BAD_REQUEST, headers: [ + 'X-Test-Header' => 'test', + ]); $this->assertSame(Status::BAD_REQUEST->value, $response->getStatusCode()); $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); $this->assertSame(getAppVersion(), $response->getHeaderLine('X-Application-Version')); + $this->assertSame('test', $response->getHeaderLine('X-Test-Header')); $this->assertSame($data, json_decode($response->getBody()->getContents(), true)); + + $response = api_error('error message', Status::BAD_REQUEST, opts: [ + 'callback' => fn($response) => $response->withStatus(Status::INTERNAL_SERVER_ERROR->value) + ]); + $this->assertSame(Status::INTERNAL_SERVER_ERROR->value, $response->getStatusCode()); + } + + public function test_api_message(): void + { + Config::append([ + 'api' => [ + 'response' => [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-Application-Version' => fn() => getAppVersion(), + 'Access-Control-Allow-Origin' => '*', + ], + ], + ] + ]); + + $data = ['info' => ['code' => Status::OK->value, 'message' => 'info message']]; + $response = api_message('info message', Status::OK, headers: [ + 'X-Test-Header' => 'test', + ]); + $this->assertSame(Status::OK->value, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $this->assertSame(getAppVersion(), $response->getHeaderLine('X-Application-Version')); + $this->assertSame('test', $response->getHeaderLine('X-Test-Header')); + $this->assertSame($data, json_decode($response->getBody()->getContents(), true)); + + $response = api_message('info message', Status::OK, opts: [ + 'callback' => fn($response) => $response->withStatus(Status::INTERNAL_SERVER_ERROR->value) + ]); + + $this->assertSame(Status::INTERNAL_SERVER_ERROR->value, $response->getStatusCode()); } public function test_httpClientChunks(): void @@ -461,6 +550,51 @@ class HelpersTest extends TestCase arrayToString($data, '@ '), 'When array is passed, it is converted into array text separated by delimiter.' ); + + $cl = new class implements JsonSerializable { + public function jsonSerialize(): array + { + return ['foo' => 'bar']; + } + + public function __toString(): string + { + return json_encode($this->jsonSerialize()); + } + }; + + $cl2 = new class implements Stringable { + public function __toString(): string + { + return json_encode(['foo' => 'bar']); + } + }; + + $cl3 = new class() { + public string $foo = 'bar'; + }; + + $this->assertSame( + '(baz: {"foo":"bar"})', + arrayToString(['baz' => $cl]), + 'When array contains a class that implements JsonSerializable it is converted into array string.' + ); + + $this->assertSame( + '(baz: {"foo":"bar"}), (foo: true), (bar: false)', + arrayToString([ + 'baz' => $cl2, + 'foo' => true, + 'bar' => false, + ]), + "When an object that implements Stringable is passed, it's casted to string" + ); + + $this->assertSame( + '(baz: [ (foo: bar) ])', + arrayToString(['baz' => $cl3]), + "When a class doesn't implement JsonSerializable or Stringable, it's converted to array. using object vars." + ); } public function test_isValidName(): void @@ -563,6 +697,9 @@ class HelpersTest extends TestCase isIgnoredId('home_plex', 'movie', 'guid_tvdb', '1201', '121'), 'When ignore url is passed with and ignore list does not contain the url, false is returned.' ); + + $this->expectException(InvalidArgumentException::class); + isIgnoredId('home_plex', 'not_real_type', 'guid_tvdb', '1200', '121'); } public function test_r(): void @@ -597,6 +734,32 @@ class HelpersTest extends TestCase 'When array is passed, it is converted into array and placeholders are replaced with values.' ); + $message = 'foo bar,taz'; + $context = ['obj' => ['foo' => 'bar', 'baz' => 'taz']]; + $this->assertSame( + ['message' => $message, 'context' => $context], + r_array($message, $context), + 'When non-existing placeholder is passed, string is returned as it is.' + ); + + $this->assertSame( + 'Time is: 2020-01-01T00:00:00+00:00', + r('Time is: {date}', ['date' => makeDate('2020-01-01', 'UTC')]), + 'When date is passed, it is converted into string and placeholders are replaced with values.' + ); + + $this->assertSame( + 'HTTP Status: 200', + r('HTTP Status: {status}', ['status' => Status::OK]), + 'When Int backed Enum is passed, it is converted into its value and the placeholder is replaced with it.' + ); + + $this->assertSame( + 'HTTP Method: POST', + r('HTTP Method: {method}', ['method' => Method::POST]), + 'When String backed Enum is passed, it is converted into its value and the placeholder is replaced with it.' + ); + $res = fopen('php://memory', 'r'); $this->assertSame( 'foo [resource]', @@ -759,4 +922,362 @@ class HelpersTest extends TestCase $this->fail('This function shouldn\'t throw exception when invalid file is given.'); } } + + public function test_generateRoutes() + { + $class = new class implements CacheInterface { + public array $cache = []; + public bool $throw = false; + + public function get(string $key, mixed $default = null): mixed + { + return $this->cache[$key] ?? $default; + } + + public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool + { + if ($this->throw) { + throw new class() extends \InvalidArgumentException implements + \Psr\SimpleCache\InvalidArgumentException { + }; + } + + $this->cache[$key] = $value; + return true; + } + + public function delete(string $key): bool + { + unset($this->cache[$key]); + return true; + } + + public function clear(): bool + { + $this->cache = []; + return true; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + foreach ($keys as $key) { + yield $key => $this->get($key, $default); + } + } + + public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool + { + foreach ($values as $key => $value) { + $this->set($key, $value, $ttl); + } + return true; + } + + public function deleteMultiple(iterable $keys): bool + { + foreach ($keys as $key) { + $this->delete($key); + } + return true; + } + + public function has(string $key): bool + { + return isset($this->cache[$key]); + } + + public function reset(): void + { + $this->cache = []; + } + }; + + $routes = generateRoutes('cli', [CacheInterface::class => $class]); + + $this->assertCount(2, $class->cache, 'It should have generated two cache buckets for http and cli routes.'); + $this->assertGreaterThanOrEqual( + 1, + count($class->cache['routes_cli']), + 'It should have more than 1 route for cli routes.' + ); + $this->assertGreaterThanOrEqual( + 1, + count($class->cache['routes_http']), + 'It should have more than 1 route for cli routes.' + ); + + $this->assertSame( + $routes, + $class->cache['routes_cli'], + 'It should return cli routes when called with cli type.' + ); + + $class->reset(); + + $this->assertSame( + generateRoutes('http', [CacheInterface::class => $class]), + $class->cache['routes_http'], + 'It should return http routes. when called with http type.' + ); + + $class->reset(); + $class->throw = true; + $routes = generateRoutes('http', [CacheInterface::class => $class]); + $this->assertCount(0, $class->cache, 'When cache throws exception, it should not save anything.'); + $this->assertNotSame([], $routes, 'Routes should be generated even if cache throws exception.'); + + // -- + $save = Config::get('supported', []); + Config::save('supported', ['not_set' => 'not_set_client', 'plex' => PlexClient::class,]); + $routes = generateRoutes('http', [CacheInterface::class => $class]); + Config::save('supported', $save); + } + + public function test_getSystemMemoryInfo() + { + /** @noinspection PhpUnhandledExceptionInspection */ + $none = getSystemMemoryInfo(bin2hex(random_bytes(32))); + $this->assertIsArray($none, 'It should return array.'); + $this->assertSame([], $none, 'When mem-file is not readable, it should return empty array.'); + + $info = getSystemMemoryInfo(__DIR__ . '/../Fixtures/meminfo_data.txt'); + $this->assertIsArray($info, 'It should return array of memory info.'); + $this->assertArrayHasKey('mem_total', $info, 'It should have total memory key.'); + $this->assertArrayHasKey('mem_free', $info, 'It should have free memory key.'); + $this->assertArrayHasKey('mem_available', $info, 'It should have available memory key.'); + $this->assertArrayHasKey('swap_total', $info, 'It should have swap total key.'); + $this->assertArrayHasKey('swap_free', $info, 'It should have swap free key.'); + + $keysValues = [ + "mem_total" => 131598708000.0, + "mem_free" => 10636272000.0, + "mem_available" => 113059644000.0, + "swap_total" => 144758584000.0, + "swap_free" => 140512824000.0, + ]; + + foreach ($keysValues as $key => $value) { + $this->assertSame($value, $info[$key], "It should have correct value for {$key} key."); + } + + if (is_writeable(sys_get_temp_dir())) { + try { + $fileName = tempnam(sys_get_temp_dir(), 'meminfo'); + $none = getSystemMemoryInfo($fileName); + $this->assertIsArray($none, 'It should return array.'); + $this->assertSame([], $none, 'When mem-file is empty it should return empty array.'); + } finally { + if (file_exists($fileName)) { + unlink($fileName); + } + } + } else { + $this->markTestSkipped('Temp directory is not writable.'); + } + } + + public function test_checkIgnoreRule() + { + Config::save('servers', ['test_backend' => []]); + $rule = 'movie://tvdb:276923@test_backend?id=133367'; + $this->assertTrue(checkIgnoreRule($rule)); + + // -- if no db source is given, it should throw exception. + $exception = null; + try { + checkIgnoreRule('movie://test_backend?id=133367&garbage=1'); + } catch (\Throwable $e) { + $exception = $e; + } finally { + $this->assertSame(RuntimeException::class, $exception ? $exception::class : null); + $this->assertSame( + 'No db source was given.', + $exception?->getMessage(), + 'When no db source is given, it should throw exception.' + ); + } + + $exception = null; + try { + checkIgnoreRule('movie://foo@test_backend?id=133367&garbage=1'); + } catch (\Throwable $e) { + $exception = $e; + } finally { + $this->assertSame(RuntimeException::class, $exception ? $exception::class : null); + $this->assertStringContainsString( + "Invalid db source name 'foo' was given.", + $exception?->getMessage(), + 'When invalid db source is given, it should throw exception.' + ); + } + + $exception = null; + try { + checkIgnoreRule('movie://tvdb@test_backend?id=133367&garbage=1'); + } catch (\Throwable $e) { + $exception = $e; + } finally { + $this->assertSame(RuntimeException::class, $exception ? $exception::class : null); + $this->assertSame( + 'No external id was given.', + $exception?->getMessage(), + 'When no external id is given in the password part of url, it should throw exception.' + ); + } + + $exception = null; + try { + checkIgnoreRule('http://tvdb:123@test_backend?id=133367&garbage=1'); + } catch (\Throwable $e) { + $exception = $e; + } finally { + $this->assertSame( + RuntimeException::class, + $exception ? $exception::class : null, + $exception?->getMessage() ?? '' + ); + $this->assertStringContainsString( + "Invalid type 'http' was given.", + $exception?->getMessage(), + 'When invalid type is given, it should throw exception.' + ); + } + + $exception = null; + try { + checkIgnoreRule('movie://tvdb:123@not_set?id=133367&garbage=1'); + } catch (\Throwable $e) { + $exception = $e; + } finally { + $this->assertSame( + RuntimeException::class, + $exception ? $exception::class : null, + $exception?->getMessage() ?? '' + ); + $this->assertStringContainsString( + "Invalid backend name 'not_set' was given.", + $exception?->getMessage(), + 'When invalid backend name is given, it should throw exception.' + ); + } + + $exception = null; + try { + checkIgnoreRule('//tvdb:123@not_set?id=133367&garbage=1'); + } catch (\Throwable $e) { + $exception = $e; + } finally { + $this->assertSame( + RuntimeException::class, + $exception ? $exception::class : null, + $exception?->getMessage() ?? '' + ); + $this->assertStringContainsString( + 'No type was given.', + $exception?->getMessage(), + 'When no type is given, it should throw exception.' + ); + } + $exception = null; + try { + checkIgnoreRule('//'); + } catch (\Throwable $e) { + $exception = $e; + } finally { + $this->assertSame( + RuntimeException::class, + $exception ? $exception::class : null, + $exception?->getMessage() ?? '' + ); + $this->assertStringContainsString( + 'Invalid ignore rule was given.', + $exception?->getMessage(), + 'When parse_url fails to parse url, it should throw exception.' + ); + } + } + + public function test_addCors() + { + $response = api_response(Status::OK, headers: ['X-Request-Id' => '1']); + $response = addCors($response, headers: [ + 'X-Test-Add' => 'test', + 'X-Request-Id' => '2', + ], methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']); + + $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertSame( + 'GET, POST, PUT, DELETE, PATCH, OPTIONS', + $response->getHeaderLine('Access-Control-Allow-Methods') + ); + $this->assertSame( + 'X-Application-Version, X-Request-Id, *', + $response->getHeaderLine('Access-Control-Allow-Headers') + ); + $this->assertGreaterThanOrEqual(600, (int)$response->getHeaderLine('Access-Control-Max-Age')); + $this->assertSame('test', $response->getHeaderLine('X-Test-Add')); + $this->assertSame('1', $response->getHeaderLine('X-Request-Id'), 'The original header should not be altered.'); + $this->assertNotSame( + '2', + $response->getHeaderLine('X-Request-Id'), + 'AddCors: headers should not alter already set headers.' + ); + } + + public function test_deepArrayMerge() + { + $array1 = [ + 'foo' => 'bar', + 'baz' => 'taz', + 'kaz' => [ + 'raz' => 'maz', + 'naz' => 'laz', + ], + ]; + + $array2 = [ + 'foo' => 'baz', + 'kaz' => [ + 'raz' => 'baz', + 'naz' => 'baz', + ], + ]; + + $expected = [ + 'foo' => 'baz', + 'baz' => 'taz', + 'kaz' => [ + 'raz' => 'baz', + 'naz' => 'baz', + ], + ]; + + $this->assertSame($expected, deepArrayMerge([$array1, $array2]), 'It should merge arrays correctly.'); + $this->assertSame( + [['foo' => 'baz'], ['baz' => 'taz'],], + deepArrayMerge([[['foo' => 'bar']], [['foo' => 'baz'], ['baz' => 'taz'],]], true), + 'if preserve keys is true' + ); + + $this->assertSame( + [['foo' => 'bar'], ['foo' => 'baz'], ['baz' => 'taz'],], + deepArrayMerge([[['foo' => 'bar']], [['foo' => 'baz'], ['baz' => 'taz'],]], false), + 'if preserve keys is false' + ); + } + + public function test_tryCatch() + { + $f = null; + $x = tryCatch(fn() => throw new RuntimeException(), fn($e) => $e, function () use (&$f) { + $f = 'finally_was_called'; + }); + + $this->assertInstanceOf( + RuntimeException::class, + $x, + 'When try block is successful, it should return the value.' + ); + $this->assertSame('finally_was_called', $f, 'finally block should be executed.'); + } } From 3a53b778108b00a29dfba1282f462566ba83c58f Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A." Date: Sat, 7 Sep 2024 15:20:06 +0300 Subject: [PATCH 8/9] Expanded test coverage. --- config/services.php | 5 + src/Commands/System/ReportCommand.php | 2 +- src/Libs/helpers.php | 71 +++- tests/Fixtures/local_data/fanart.png | 0 tests/Fixtures/local_data/poster.jpg | 0 tests/Fixtures/local_data/test.mkv | 1 + tests/Fixtures/local_data/test.png | 0 tests/Fixtures/local_data/test.srt | 1 + tests/Fixtures/test_servers.yaml | 62 ++++ tests/Libs/HelpersTest.php | 507 ++++++++++++++++++++++---- tests/bootstrap.php | 4 + 11 files changed, 560 insertions(+), 93 deletions(-) create mode 100644 tests/Fixtures/local_data/fanart.png create mode 100644 tests/Fixtures/local_data/poster.jpg create mode 100644 tests/Fixtures/local_data/test.mkv create mode 100644 tests/Fixtures/local_data/test.png create mode 100644 tests/Fixtures/local_data/test.srt create mode 100644 tests/Fixtures/test_servers.yaml diff --git a/config/services.php b/config/services.php index 4e0b69d5..26dc08c1 100644 --- a/config/services.php +++ b/config/services.php @@ -24,6 +24,7 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Http\Message\UriInterface; use Psr\Log\LoggerInterface as iLogger; use Psr\SimpleCache\CacheInterface; +use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Cache\Adapter\NullAdapter; use Symfony\Component\Cache\Adapter\RedisAdapter; @@ -121,6 +122,10 @@ return (function (): array { return new Psr16Cache(new NullAdapter()); } + if (true === (defined('IN_TEST_MODE') && true === IN_TEST_MODE)) { + return new Psr16Cache(new ArrayAdapter()); + } + $ns = getAppVersion(); if (null !== ($prefix = Config::get('cache.prefix')) && true === isValidName($prefix)) { diff --git a/src/Commands/System/ReportCommand.php b/src/Commands/System/ReportCommand.php index cf93277e..4b847b31 100644 --- a/src/Commands/System/ReportCommand.php +++ b/src/Commands/System/ReportCommand.php @@ -103,7 +103,7 @@ final class ReportCommand extends Command $output->writeln( r('Is the tasks runner working? {answer}', [ 'answer' => (function () { - $info = isTaskWorkerRunning(true); + $info = isTaskWorkerRunning(ignoreContainer: true); return r("{status} '{container}' - {message}", [ 'status' => $info['status'] ? 'Yes' : 'No', 'message' => $info['message'], diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index 47ada885..56732ace 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -1539,10 +1539,6 @@ if (!function_exists('parseEnvFile')) { } foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { - if (empty($line)) { - continue; - } - if (true === str_starts_with($line, '#') || false === str_contains($line, '=')) { continue; } @@ -1611,11 +1607,12 @@ if (!function_exists('isTaskWorkerRunning')) { /** * Check if the task worker is running. This function is only available when running in a container. * + * @param string $pidFile (Optional) The PID file to check. * @param bool $ignoreContainer (Optional) Whether to ignore the container check. * * @return array{ status: bool, message: string } */ - function isTaskWorkerRunning(bool $ignoreContainer = false): array + function isTaskWorkerRunning(string $pidFile = '/tmp/ws-job-runner.pid', bool $ignoreContainer = false): array { if (false === $ignoreContainer && !inContainer()) { return [ @@ -1633,8 +1630,6 @@ if (!function_exists('isTaskWorkerRunning')) { ]; } - $pidFile = '/tmp/ws-job-runner.pid'; - if (!file_exists($pidFile)) { return [ 'status' => false, @@ -1649,7 +1644,33 @@ if (!function_exists('isTaskWorkerRunning')) { return ['status' => false, 'message' => $e->getMessage()]; } - if (file_exists(r('/proc/{pid}/status', ['pid' => $pid]))) { + switch (PHP_OS) { + case 'Linux': + { + $status = file_exists(r('/proc/{pid}/status', ['pid' => $pid])); + } + break; + case 'WINNT': + { + // -- Windows does not have a /proc directory so we need different way to get the status. + @exec("tasklist /FI \"PID eq {$pid}\" 2>NUL", $output); + // -- windows doesn't return 0 if the process is not found. we need to parse the output. + $status = false; + foreach ($output as $line) { + if (false === str_contains($line, $pid)) { + continue; + } + $status = true; + break; + } + } + break; + default: + $status = false; + break; + } + + if (true === $status) { return ['status' => true, 'restartable' => true, 'message' => 'Task worker is running.']; } @@ -1863,6 +1884,7 @@ if (!function_exists('cacheableItem')) { * @param Closure $function * @param DateInterval|int|null $ttl * @param bool $ignoreCache + * @param array $opts * * @return mixed */ @@ -1870,15 +1892,16 @@ if (!function_exists('cacheableItem')) { string $key, Closure $function, DateInterval|int|null $ttl = null, - bool $ignoreCache = false + bool $ignoreCache = false, + array $opts = [], ): mixed { - $cache = Container::get(iCache::class); + $cache = $opts[iCache::class] ?? Container::get(iCache::class); if (!$ignoreCache && $cache->has($key)) { return $cache->get($key); } - $reflectContainer = Container::get(ReflectionContainer::class); + $reflectContainer = $opts[ReflectionContainer::class] ?? Container::get(ReflectionContainer::class); $item = $reflectContainer->call($function); if (null === $ttl) { @@ -2048,8 +2071,9 @@ if (!function_exists('getBackend')) { $default = $configFile->get($name); $default['name'] = $name; + $data = array_replace_recursive($default, $config); - return makeBackend(array_replace_recursive($default, $config), $name); + return makeBackend($data, $name); } } @@ -2094,3 +2118,26 @@ if (!function_exists('lw')) { ]; } } + +if (!function_exists('timeIt')) { + /** + * Time the execution of a function. + * + * @param Closure $function The function to time. + * @param string $name The name of the function. + * @param int $round (Optional) The number of decimal places to round to. + * + * @return string + */ + function timeIt(Closure $function, string $name, int $round = 6): string + { + $start = microtime(true); + $function(); + $end = microtime(true); + + return r("Execution time is '{time}' for '{name}'", [ + 'name' => $name, + 'time' => round($end - $start, $round), + ]); + } +} diff --git a/tests/Fixtures/local_data/fanart.png b/tests/Fixtures/local_data/fanart.png new file mode 100644 index 00000000..e69de29b diff --git a/tests/Fixtures/local_data/poster.jpg b/tests/Fixtures/local_data/poster.jpg new file mode 100644 index 00000000..e69de29b diff --git a/tests/Fixtures/local_data/test.mkv b/tests/Fixtures/local_data/test.mkv new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/tests/Fixtures/local_data/test.mkv @@ -0,0 +1 @@ +0 diff --git a/tests/Fixtures/local_data/test.png b/tests/Fixtures/local_data/test.png new file mode 100644 index 00000000..e69de29b diff --git a/tests/Fixtures/local_data/test.srt b/tests/Fixtures/local_data/test.srt new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/tests/Fixtures/local_data/test.srt @@ -0,0 +1 @@ +0 diff --git a/tests/Fixtures/test_servers.yaml b/tests/Fixtures/test_servers.yaml new file mode 100644 index 00000000..c06f8182 --- /dev/null +++ b/tests/Fixtures/test_servers.yaml @@ -0,0 +1,62 @@ +test_plex: + type: plex + url: 'https://plex.example.invalid' + token: t000000000000000000p + user: 11111111 + uuid: s00000000000000000000000000000000000000p + export: + enabled: true + lastSync: null + import: + enabled: true + lastSync: 1724173445 + webhook: + match: + user: true + uuid: true + options: + ignore: '22,1,2,3' + LIBRARY_SEGMENT: 1000 + ADMIN_TOKEN: plex_admin_token + use_old_progress_endpoint: true + plex_user_uuid: r00000000000000p + +test_jellyfin: + type: jellyfin + url: 'https://jellyfin.example.invalid' + token: t000000000000000000000000000000j + user: u000000000000000000000000000000j + uuid: s000000000000000000000000000000j + export: + enabled: true + lastSync: null + import: + enabled: true + lastSync: 1724173445 + webhook: + match: + user: false + uuid: true + options: + ignore: 'i000000000000000000000000000000j,i100000000000000000000000000000j' + MAX_EPISODE_RANGE: 6 + +test_emby: + type: emby + url: 'https://emby.example.invalid' + token: t000000000000000000000000000000e + user: u000000000000000000000000000000e + uuid: s000000000000000000000000000000e + import: + enabled: true + lastSync: 1724173445 + export: + enabled: true + lastSync: null + webhook: + match: + user: false + uuid: true + options: + MAX_EPISODE_RANGE: 6 + IMPORT_METADATA_ONLY: true diff --git a/tests/Libs/HelpersTest.php b/tests/Libs/HelpersTest.php index cb5b423b..05b67e61 100644 --- a/tests/Libs/HelpersTest.php +++ b/tests/Libs/HelpersTest.php @@ -6,11 +6,16 @@ namespace Tests\Libs; use App\Backends\Plex\PlexClient; use App\Libs\Config; +use App\Libs\Container; +use App\Libs\Database\DBLayer; use App\Libs\Entity\StateEntity; use App\Libs\Enums\Http\Method; use App\Libs\Enums\Http\Status; +use App\Libs\Exceptions\AppExceptionInterface; +use App\Libs\Exceptions\DBLayerException; use App\Libs\Exceptions\InvalidArgumentException; use App\Libs\Exceptions\RuntimeException; +use App\Libs\Extends\ReflectionContainer; use App\Libs\TestCase; use JsonMachine\Items; use JsonMachine\JsonDecoder\ErrorWrappingDecoder; @@ -23,10 +28,84 @@ use Psr\SimpleCache\CacheInterface; use Stringable; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\Yaml\Yaml; use TypeError; class HelpersTest extends TestCase { + protected CacheInterface|null $cache = null; + + protected function setUp(): void + { + parent::setUp(); + $this->cache = new class implements CacheInterface { + public array $cache = []; + public bool $throw = false; + + public function get(string $key, mixed $default = null): mixed + { + return $this->cache[$key] ?? $default; + } + + public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool + { + if ($this->throw) { + throw new class() extends \InvalidArgumentException implements + \Psr\SimpleCache\InvalidArgumentException { + }; + } + + $this->cache[$key] = $value; + return true; + } + + public function delete(string $key): bool + { + unset($this->cache[$key]); + return true; + } + + public function clear(): bool + { + $this->cache = []; + return true; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + foreach ($keys as $key) { + yield $key => $this->get($key, $default); + } + } + + public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool + { + foreach ($values as $key => $value) { + $this->set($key, $value, $ttl); + } + return true; + } + + public function deleteMultiple(iterable $keys): bool + { + foreach ($keys as $key) { + $this->delete($key); + } + return true; + } + + public function has(string $key): bool + { + return isset($this->cache[$key]); + } + + public function reset(): void + { + $this->cache = []; + } + }; + } + public function test_env_conditions(): void { $values = [ @@ -925,111 +1004,48 @@ class HelpersTest extends TestCase public function test_generateRoutes() { - $class = new class implements CacheInterface { - public array $cache = []; - public bool $throw = false; + $routes = generateRoutes('cli', [CacheInterface::class => $this->cache]); - public function get(string $key, mixed $default = null): mixed - { - return $this->cache[$key] ?? $default; - } - - public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool - { - if ($this->throw) { - throw new class() extends \InvalidArgumentException implements - \Psr\SimpleCache\InvalidArgumentException { - }; - } - - $this->cache[$key] = $value; - return true; - } - - public function delete(string $key): bool - { - unset($this->cache[$key]); - return true; - } - - public function clear(): bool - { - $this->cache = []; - return true; - } - - public function getMultiple(iterable $keys, mixed $default = null): iterable - { - foreach ($keys as $key) { - yield $key => $this->get($key, $default); - } - } - - public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool - { - foreach ($values as $key => $value) { - $this->set($key, $value, $ttl); - } - return true; - } - - public function deleteMultiple(iterable $keys): bool - { - foreach ($keys as $key) { - $this->delete($key); - } - return true; - } - - public function has(string $key): bool - { - return isset($this->cache[$key]); - } - - public function reset(): void - { - $this->cache = []; - } - }; - - $routes = generateRoutes('cli', [CacheInterface::class => $class]); - - $this->assertCount(2, $class->cache, 'It should have generated two cache buckets for http and cli routes.'); + $this->assertCount( + 2, + $this->cache->cache, + 'It should have generated two cache buckets for http and cli routes.' + ); $this->assertGreaterThanOrEqual( 1, - count($class->cache['routes_cli']), + count($this->cache->cache['routes_cli']), 'It should have more than 1 route for cli routes.' ); $this->assertGreaterThanOrEqual( 1, - count($class->cache['routes_http']), + count($this->cache->cache['routes_http']), 'It should have more than 1 route for cli routes.' ); $this->assertSame( $routes, - $class->cache['routes_cli'], + $this->cache->cache['routes_cli'], 'It should return cli routes when called with cli type.' ); - $class->reset(); + $this->cache->reset(); $this->assertSame( - generateRoutes('http', [CacheInterface::class => $class]), - $class->cache['routes_http'], + generateRoutes('http', [CacheInterface::class => $this->cache]), + $this->cache->cache['routes_http'], 'It should return http routes. when called with http type.' ); - $class->reset(); - $class->throw = true; - $routes = generateRoutes('http', [CacheInterface::class => $class]); - $this->assertCount(0, $class->cache, 'When cache throws exception, it should not save anything.'); + $this->cache->reset(); + $this->cache->throw = true; + $routes = generateRoutes('http', [CacheInterface::class => $this->cache]); + $this->assertCount(0, $this->cache->cache, 'When cache throws exception, it should not save anything.'); $this->assertNotSame([], $routes, 'Routes should be generated even if cache throws exception.'); // -- $save = Config::get('supported', []); Config::save('supported', ['not_set' => 'not_set_client', 'plex' => PlexClient::class,]); - $routes = generateRoutes('http', [CacheInterface::class => $class]); + $routes = generateRoutes('http', [CacheInterface::class => $this->cache]); Config::save('supported', $save); } @@ -1280,4 +1296,335 @@ class HelpersTest extends TestCase ); $this->assertSame('finally_was_called', $f, 'finally block should be executed.'); } + + public function test_getServerColumnSpec() + { + $this->assertSame( + [ + 'key' => 'user', + 'type' => 'string', + 'visible' => true, + 'description' => 'The user ID of the backend.', + ], + getServerColumnSpec('user'), + 'It should return correct column spec.' + ); + + $this->assertSame([], getServerColumnSpec('not_set'), 'It should return empty array when column is not set.'); + } + + public function test_getEnvSpec() + { + $this->assertSame( + [ + 'key' => 'WS_DATA_PATH', + 'description' => 'Where to store main data. (config, db).', + 'type' => 'string', + ], + getEnvSpec('WS_DATA_PATH'), + 'It should return correct env spec.' + ); + + $this->assertSame([], getEnvSpec('not_set'), 'It should return empty array when env is not set.'); + } + + public function test_isTaskWorkerRunning() + { + $_ENV['IN_CONTAINER'] = false; + $d = isTaskWorkerRunning(); + $this->assertTrue($d['status'], 'When not in container, and $ignoreContainer is false, it should return true.'); + unset($_ENV['IN_CONTAINER']); + + $_ENV['DISABLE_CRON'] = true; + $d = isTaskWorkerRunning(ignoreContainer: true); + $this->assertFalse($d['status'], 'When DISABLE_CRON is set, it should return false.'); + unset($_ENV['DISABLE_CRON']); + + $d = isTaskWorkerRunning(pidFile: __DIR__ . '/../Fixtures/worker.pid', ignoreContainer: true); + $this->assertFalse($d['status'], 'When pid file is not found, it should return false.'); + + $tmpFile = tempnam(sys_get_temp_dir(), 'worker'); + try { + file_put_contents($tmpFile, getmypid()); + $d = isTaskWorkerRunning(pidFile: $tmpFile, ignoreContainer: true); + $this->assertTrue($d['status'], 'When pid file is found, and process exists it should return true.'); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + + $tmpFile = tempnam(sys_get_temp_dir(), 'worker'); + try { + /** @noinspection PhpUnhandledExceptionInspection */ + file_put_contents($tmpFile, random_int(1, 9999) . getmypid()); + $d = isTaskWorkerRunning(pidFile: $tmpFile, ignoreContainer: true); + $this->assertFalse( + $d['status'], + 'When pid file is found, and process does not exists it should return false.' + ); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + } + + public function test_findSideCarFiles() + { + $n = new \SplFileInfo(__DIR__ . '/../Fixtures/local_data/test.mkv'); + $this->assertCount( + 4, + findSideCarFiles($n), + 'It should return side car files for given file.' + ); + } + + public function test_array_change_key_case_recursive() + { + $array = [ + 'foo' => 'bar', + 'baz' => 'taz', + 'kaz' => [ + 'raz' => 'maz', + 'naz' => 'laz', + ], + ]; + + $expected = [ + 'FOO' => 'bar', + 'BAZ' => 'taz', + 'KAZ' => [ + 'RAZ' => 'maz', + 'NAZ' => 'laz', + ], + ]; + + $this->assertSame( + $expected, + array_change_key_case_recursive($array, CASE_UPPER), + 'It should change keys case.' + ); + + $this->assertSame( + $array, + array_change_key_case_recursive($expected, CASE_LOWER), + 'It should change keys case.' + ); + + $this->expectException(RuntimeException::class); + array_change_key_case_recursive($array, 999); + } + + public function test_getMimeType() + { + $this->assertSame( + 'application/json', + getMimeType(__DIR__ . '/../Fixtures/plex_data.json'), + 'It should return correct mime type.' + ); + } + + public function test_getExtension() + { + $this->assertSame( + 'json', + getExtension(__DIR__ . '/../Fixtures/plex_data.json'), + 'It should return correct extension.' + ); + } + + public function test_generateUUID() + { + #1ef6d04c-23c3-6442-9fd5-c87f54c3d8d1 + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-6[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + generateUUID(), + 'It should match valid UUID6 pattern.' + ); + + $this->assertMatchesRegularExpression( + '/^test\-[0-9a-f]{8}-[0-9a-f]{4}-6[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + generateUUID('test'), + 'It should match valid UUID6 pattern.' + ); + } + + public function test_cacheableItem() + { + $reflectContainer = new class() { + public function call(callable $callable, array $args = []): mixed + { + return $callable(...$args); + } + }; + + $item = fn() => cacheableItem( + key: 'test', + function: fn() => 'foo', + ignoreCache: false, + opts: [ + CacheInterface::class => $this->cache, + ReflectionContainer::class => $reflectContainer, + ] + ); + + $this->assertSame('foo', $item(), 'It should return correct value.'); + $this->assertSame('foo', $item(), 'It should return correct value.'); + } + + public function test_getPagination() + { + $factory = new Psr17Factory(); + $creator = new ServerRequestCreator($factory, $factory, $factory, $factory); + + $request = $creator->fromArrays([ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'page=2&perpage=10' + ], get: ['page' => 2, 'perpage' => 10]); + + [$page, $perpage, $start] = getPagination($request, 1); + + $this->assertSame(2, $page, 'It should return correct page number.'); + $this->assertSame(10, $perpage, 'It should return correct perpage number.'); + $this->assertSame(10, $start, 'It should return correct start number.'); + } + + public function test_getBackend() + { + Container::init(); + Config::init(require __DIR__ . '/../../config/config.php'); + foreach ((array)require __DIR__ . '/../../config/services.php' as $name => $definition) { + Container::add($name, $definition); + } + Config::save('backends_file', __DIR__ . '/../Fixtures/test_servers.yaml'); + + $this->assertInstanceOf( + PlexClient::class, + getBackend('test_plex'), + 'It should return correct backend client.' + ); + + $this->expectException(RuntimeException::class); + getBackend('not_set'); + } + + public function test_makeBackend() + { + Container::init(); + Config::init(require __DIR__ . '/../../config/config.php'); + foreach ((array)require __DIR__ . '/../../config/services.php' as $name => $definition) { + Container::add($name, $definition); + } + Config::save('backends_file', __DIR__ . '/../Fixtures/test_servers.yaml'); + + $exception = null; + try { + makeBackend([], 'foo'); + } catch (\Throwable $e) { + $exception = $e; + } finally { + $this->assertInstanceOf( + InvalidArgumentException::class, + $exception, + 'Should throw exception when no type is given.' + ); + $this->assertStringContainsString('No backend type was set.', $exception?->getMessage()); + } + + $exception = null; + try { + makeBackend(['type' => 'plex'], 'foo'); + } catch (\Throwable $e) { + $exception = $e; + } finally { + $this->assertInstanceOf( + InvalidArgumentException::class, + $exception, + 'Should throw exception when no url is given.' + ); + $this->assertStringContainsString('No backend url was set.', $exception?->getMessage()); + } + + $exception = null; + try { + makeBackend(['type' => 'far', 'url' => 'http://test.example.invalid'], 'foo'); + } catch (\Throwable $e) { + $exception = $e; + } finally { + $this->assertInstanceOf( + InvalidArgumentException::class, + $exception, + 'Should throw exception when no type is not supported.' + ); + $this->assertStringContainsString('Unexpected client type', $exception?->getMessage()); + } + + $data = Yaml::parseFile(__DIR__ . '/../Fixtures/test_servers.yaml'); + + $this->assertInstanceOf( + PlexClient::class, + makeBackend($data['test_plex'], 'test_plex'), + 'It should return correct backend client.' + ); + } + + public function test_lw() + { + $exception = new RuntimeException(); + $exception->addContext('foo', 'bar'); + + $this->assertSame( + [AppExceptionInterface::class => ['foo' => 'bar']], + lw('test', [], $exception)['context'], + 'it should return the added AppContext' + ); + $this->assertSame( + ['bar' => 'foo'], + lw('test', ['bar' => 'foo'], new \RuntimeException())['context'], + 'If exception is not AppExceptionInterface, it should return same data.' + ); + + $exception = new DBLayerException(); + /** @noinspection SqlResolve */ + $exception->setInfo('SELECT * FROM foo WHERE id = :id', ['id' => 1], [], 122); + /** @noinspection SqlResolve */ + $this->assertSame( + [ + 'bar' => 'foo', + DBLayer::class => [ + 'query' => 'SELECT * FROM foo WHERE id = :id', + 'bind' => ['id' => 1], + 'error' => [], + ], + ], + lw('test', ['bar' => 'foo'], $exception)['context'], + 'If exception is not AppExceptionInterface, it should return same data.' + ); + + $this->assertSame( + ['bar' => 'foo'], + lw('test', ['bar' => 'foo'])['context'], + 'If no exception is given, it should return same data.' + ); + } + + public function test_commandContext() + { + $_ENV['IN_CONTAINER'] = true; + $this->assertSame( + 'docker exec -ti watchstate console', + trim(commandContext()), + 'It should return correct command context. When in container.' + ); + unset($_ENV['IN_CONTAINER']); + + $_ENV['IN_CONTAINER'] = false; + $this->assertSame( + $_SERVER['argv'][0] ?? 'php bin/console', + trim(commandContext()), + 'If not in container, it should return argv[0] or defaults to php bin/console.' + ); + unset($_ENV['IN_CONTAINER']); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 9f2d88bd..7af6b8b6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -2,6 +2,10 @@ declare(strict_types=1); +if (false === defined('IN_TEST_MODE')) { + define('IN_TEST_MODE', true); +} + require __DIR__ . '/../pre_init.php'; if (!file_exists(__DIR__ . '/../vendor/autoload.php')) { From 3d2f4b3b6d7f78d47b076e07328470d8e16fdf30 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A." Date: Sat, 7 Sep 2024 17:15:24 +0300 Subject: [PATCH 9/9] Added Testcase for ConfigFile class. --- src/Libs/ConfigFile.php | 2 +- src/Libs/Guid.php | 16 +- src/Libs/TestCase.php | 44 ++++++ tests/Libs/ConfigFileTest.php | 267 ++++++++++++++++++++++++++++++++++ 4 files changed, 320 insertions(+), 9 deletions(-) create mode 100644 tests/Libs/ConfigFileTest.php diff --git a/src/Libs/ConfigFile.php b/src/Libs/ConfigFile.php index e6ed0227..ae810e75 100644 --- a/src/Libs/ConfigFile.php +++ b/src/Libs/ConfigFile.php @@ -161,7 +161,7 @@ final class ConfigFile implements ArrayAccess, LoggerAwareInterface if (false === $override) { clearstatcache(true, $this->file); $newHash = $this->getFileHash(); - if ($newHash !== $this->file_hash) { + if (false === hash_equals($this->file_hash, $newHash)) { $this->logger?->warning( "File '{file}' has been modified since last load. re-applying changes on top of the new data.", [ diff --git a/src/Libs/Guid.php b/src/Libs/Guid.php index 4ce6e572..2b0ce649 100644 --- a/src/Libs/Guid.php +++ b/src/Libs/Guid.php @@ -6,7 +6,7 @@ namespace App\Libs; use App\Libs\Exceptions\InvalidArgumentException; use JsonSerializable; -use Psr\Log\LoggerInterface; +use Psr\Log\LoggerInterface as iLogger; use Stringable; /** @@ -91,9 +91,9 @@ final class Guid implements JsonSerializable, Stringable */ private array $data = []; /** - * @var null|LoggerInterface $logger The logger instance used for logging. + * @var null|iLogger $logger The logger instance used for logging. */ - private static LoggerInterface|null $logger = null; + private static iLogger|null $logger = null; /** * Create list of db => external id list. @@ -172,9 +172,9 @@ final class Guid implements JsonSerializable, Stringable /** * Set the logger instance for the class. * - * @param LoggerInterface $logger The logger instance to be set. + * @param iLogger $logger The logger instance to be set. */ - public static function setLogger(LoggerInterface $logger): void + public static function setLogger(iLogger $logger): void { self::$logger = $logger; } @@ -279,12 +279,12 @@ final class Guid implements JsonSerializable, Stringable /** * Get instance of logger. * - * @return LoggerInterface + * @return iLogger */ - private function getLogger(): LoggerInterface + private function getLogger(): iLogger { if (null === self::$logger) { - self::$logger = Container::get(LoggerInterface::class); + self::$logger = Container::get(iLogger::class); } return self::$logger; diff --git a/src/Libs/TestCase.php b/src/Libs/TestCase.php index ff02d90c..8ee72f26 100644 --- a/src/Libs/TestCase.php +++ b/src/Libs/TestCase.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace App\Libs; +use Closure; use Monolog\Handler\TestHandler; +use Throwable; class TestCase extends \PHPUnit\Framework\TestCase { @@ -25,4 +27,46 @@ class TestCase extends \PHPUnit\Framework\TestCase fwrite(STDOUT, $logs['formatted'] . PHP_EOL); } } + + /** + * Checks if the given closure throws an exception. + * + * @param Closure $closure + * @param string $reason + * @param Throwable|string $exception Expected exception class + * @param string $exceptionMessage (optional) Exception message + * @param int|null $exceptionCode (optional) Exception code + * @return void + */ + protected function checkException( + Closure $closure, + string $reason, + Throwable|string $exception, + string $exceptionMessage = '', + int|null $exceptionCode = null, + ): void { + $caught = null; + try { + $closure(); + } catch (Throwable $e) { + $caught = $e; + } finally { + if (null === $caught) { + $this->fail($reason); + } else { + $this->assertInstanceOf( + is_object($exception) ? $exception::class : $exception, + $caught, + $reason + ); + if (!empty($exceptionMessage)) { + $this->assertStringContainsString($exceptionMessage, $caught->getMessage(), $reason); + } + if (!empty($exceptionCode)) { + $this->assertEquals($exceptionCode, $caught->getCode(), $reason); + } + } + } + } + } diff --git a/tests/Libs/ConfigFileTest.php b/tests/Libs/ConfigFileTest.php new file mode 100644 index 00000000..2bf84941 --- /dev/null +++ b/tests/Libs/ConfigFileTest.php @@ -0,0 +1,267 @@ + 'bar', 'baz' => 'kaz', 'sub' => ['key' => 'val']]; + + private array $params = []; + private Logger|null $logger = null; + + protected function setUp(): void + { + parent::setUp(); + + $this->handler = new TestHandler(); + $this->logger = new Logger('logger', processors: [new LogMessageProcessor()]); + $this->logger->pushHandler($this->handler); + + $this->params = [ + 'file' => __DIR__ . '/../Fixtures/test_servers.yaml', + 'type' => 'yaml', + 'autoSave' => false, + 'autoCreate' => false, + 'autoBackup' => false, + 'opts' => [ + 'json_decode' => JSON_UNESCAPED_UNICODE, + 'json_encode' => JSON_UNESCAPED_UNICODE, + ], + ]; + } + + public function test_constructor() + { + $this->checkException( + closure: fn() => new ConfigFile( + 'nonexistent.json', + 'json', + autoSave: false, + autoCreate: false, + autoBackup: false + ), + reason: 'If file does not exist, and autoCreate is set to false, an exception should be thrown.', + exception: InvalidArgumentException::class, + exceptionMessage: "File 'nonexistent.json' does not exist.", + ); + + $this->checkException( + closure: fn() => new ConfigFile( + 'nonexistent.json', + 'php', + autoSave: false, + autoCreate: false, + autoBackup: false + ), + reason: 'If type is not supported, an exception should be thrown.', + exception: InvalidArgumentException::class, + exceptionMessage: "Invalid content type 'php'.", + ); + + $this->checkException( + closure: fn() => new ConfigFile( + '/root/test.json', + 'json', + autoSave: false, + autoCreate: true, + autoBackup: false + ), + reason: 'If file is not writable, an exception should be thrown.', + exception: InvalidArgumentException::class, + exceptionMessage: "could not be created", + ); + + try { + $class = new ConfigFile(...$this->params); + $this->assertInstanceOf(ConfigFile::class, $class); + } catch (Throwable $e) { + $this->fail('If all conditions are met, NO exception should be been thrown. ' . $e->getMessage()); + } + + try { + $class = ConfigFile::open(...$this->params); + $this->assertInstanceOf(ConfigFile::class, $class); + } catch (Throwable $e) { + $this->fail('If all conditions are met, NO exception should be been thrown. ' . $e->getMessage()); + } + } + + public function test_setLogger() + { + $tmpFile = tempnam(sys_get_temp_dir(), 'test'); + copy(__DIR__ . '/../Fixtures/test_servers.yaml', $tmpFile); + $params = $this->params; + $params['file'] = $tmpFile; + $params['autoBackup'] = true; + $class = new ConfigFile(...$params); + try { + $class->setLogger($this->logger); + $class->set('foo', 'bar'); + $class->delete('kaz'); + // -- trigger external change + ConfigFile::open(...$params)->set('bar', 'kaz')->persist(); + // -- should trigger warning. + $class->persist(); + $this->assertStringContainsString( + 'has been modified since last load.', + $this->handler->getRecords()[0]['message'] + ); + } catch (Throwable $e) { + $this->fail('If correct logger is passed, no exception should be thrown. ' . $e->getMessage()); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + if (file_exists($tmpFile . '.bak')) { + unlink($tmpFile . '.bak'); + } + } + } + + public function test_delete() + { + $tmpFile = tempnam(sys_get_temp_dir(), 'test'); + copy(__DIR__ . '/../Fixtures/test_servers.yaml', $tmpFile); + $params = $this->params; + $params['file'] = $tmpFile; + + try { + $this->assertArrayNotHasKey( + 'test_jellyfin', + ConfigFile::open(...$params)->delete('test_jellyfin')->getAll(), + '->delete: Failed to delete key from YAML file.' + ); + + $class = ConfigFile::open(...$params); + unset($class['test_jellyfin']); + $this->assertArrayNotHasKey( + 'test_jellyfin', + $class->getAll(), + 'ArrayAccess: Failed to delete key from YAML file.' + ); + } catch (Throwable $e) { + $this->fail('If correct logger is passed, no exception should be thrown. ' . $e->getMessage()); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + } + + public function test_get() + { + $class = new ConfigFile(...$this->params); + + $this->assertArrayHasKey( + 'token', + $class->get('test_plex', []), + '->get: Invalid response from parsing YAML file.' + ); + $this->assertArrayHasKey('token', $class['test_plex'], 'ArrayAccess: Invalid response from parsing YAML file.'); + + $this->assertArrayNotHasKey( + 'token', + $class->get('test_not_set', []), + 'Invalid response from parsing YAML file.' + ); + $this->assertNull($class['test_not_set'], 'ArrayAccess: Must return null if key does not exist.'); + } + + public function test_has() + { + $class = new ConfigFile(...$this->params); + $this->assertTrue($class->has('test_plex'), '->has: Must return true if key exists.'); + $this->assertTrue(isset($class['test_plex']), 'ArrayAccess: Must return true if key exists.'); + + $this->assertFalse($class->has('test_not_set'), '->has: Must return false if key does not exist.'); + $this->assertFalse(isset($class['test_not_set']), 'ArrayAccess: Must return false if key does not exist.'); + } + + public function test_set() + { + $tmpFile = tempnam(sys_get_temp_dir(), 'test'); + copy(__DIR__ . '/../Fixtures/test_servers.yaml', $tmpFile); + $params = $this->params; + $params['file'] = $tmpFile; + + try { + $this->assertArrayHasKey( + 'test_foo', + ConfigFile::open(...$params)->set('test_foo', $this->data)->getAll(), + '->set: Failed to set key in YAML file.' + ); + + $class = ConfigFile::open(...$params); + $class['test_foo'] = $this->data; + $this->assertArrayHasKey('test_foo', $class->getAll(), 'ArrayAccess: Failed to set key.'); + + // -- test deep array. + $class->set('test_plex.options.foo', 'bar'); + $this->assertArrayHasKey('foo', $class->get('test_plex.options', []), 'Failed to set deep key.'); + + $class['foo'] = ['bar' => ['jaz' => ['kaz' => 'baz']]]; + $this->assertArrayHasKey('foo', $class->getAll(), 'ArrayAccess: Failed to set key.'); + $class->set('foo', ['bar' => ['tax' => 'max']]); + + $class->override()->persist(); + $class = ConfigFile::open(...$params); + + $this->assertArrayHasKey('test_foo', $class->getAll(), 'failed to persist changes.'); + $this->assertArrayHasKey('foo', $class->getAll(), 'ArrayAccess: failed to persist changes.'); + $this->assertArrayHasKey('bar', $class->get('foo', []), 'failed to persist changes.'); + } catch (Throwable $e) { + $this->fail('If correct logger is passed, no exception should be thrown. ' . $e->getMessage()); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + } + + public function test_configFile_set() + { + $params['file'] = tempnam(sys_get_temp_dir(), 'test'); + + try { + try { + copy(__DIR__ . '/../Fixtures/test_servers.yaml', $params['file']); + $class = new ConfigFile(...$params); + $this->assertInstanceOf(ConfigFile::class, $class); + } catch (Throwable $e) { + $this->fail('If file exists and is readable, no exception should be thrown. ' . $e->getMessage()); + } + + $class = ConfigFile::open(...$params); + $this->assertInstanceOf(ConfigFile::class, $class); + + $this->assertArrayHasKey('token', $class->get('test_plex', []), 'Invalid response from parsing YAML file.'); + $this->assertArrayNotHasKey( + 'token', + $class->get('test_not_set', []), + 'Invalid response from parsing YAML file.' + ); + $this->assertTrue($class->has('test_plex'), 'Must return true if key exists.'); + $this->assertFalse($class->has('test_not_set'), 'Must return false if key does not exist.'); + + + $this->assertArrayHasKey('token', $class['test_plex'], 'Failed to get arrayAccess key correctly.'); + $this->assertTrue(isset($class['test_plex']), 'Must return true if arrayAccess key exists.'); + $this->assertNull($class['test_not_set'], 'Must return null if arrayAccess key does not exist.'); + $this->assertFalse(isset($class['test_not_set']), 'Must return false if arrayAccess key does not exist.'); + } finally { + if (file_exists($params['file'])) { + unlink($params['file']); + } + } + } +}