diff --git a/config/services.php b/config/services.php index 26dc08c1..7d6df3f7 100644 --- a/config/services.php +++ b/config/services.php @@ -187,12 +187,17 @@ return (function (): array { PDO::class => [ 'class' => function (): PDO { - $dbFile = Config::get('database.file'); - $changePerm = !file_exists($dbFile); + $inTestMode = true === (defined('IN_TEST_MODE') && true === IN_TEST_MODE); + $dsn = $inTestMode ? 'sqlite::memory:' : Config::get('database.dsn'); - $pdo = new PDO(dsn: Config::get('database.dsn'), options: Config::get('database.options', [])); + if (false === $inTestMode) { + $dbFile = Config::get('database.file'); + $changePerm = !file_exists($dbFile); + } - if ($changePerm && inContainer() && 777 !== (int)(decoct(fileperms($dbFile) & 0777))) { + $pdo = new PDO(dsn: $dsn, options: Config::get('database.options', [])); + + if (!$inTestMode && $changePerm && inContainer() && 777 !== (int)(decoct(fileperms($dbFile) & 0777))) { @chmod($dbFile, 0777); } diff --git a/src/Commands/State/ExportCommand.php b/src/Commands/State/ExportCommand.php index 7840b7a9..c98e9a86 100644 --- a/src/Commands/State/ExportCommand.php +++ b/src/Commands/State/ExportCommand.php @@ -563,8 +563,6 @@ class ExportCommand extends Command ], ]); - $this->db->singleTransaction(); - $requests = []; foreach ($backends as $backend) { @@ -601,6 +599,7 @@ class ExportCommand extends Command } } + $start = makeDate(); $this->logger->notice("SYSTEM: Sending '{total}' play state comparison requests.", [ 'total' => count($requests), ]); @@ -614,9 +613,25 @@ class ExportCommand extends Command } } - $this->logger->notice("SYSTEM: Sent '{total}' play state comparison requests.", [ - 'total' => count($requests), - ]); + $end = makeDate(); + $this->logger->notice( + "SYSTEM: Completed '{total}' play state comparison requests in '{time.duration}'s. Parsed '{responses.size}' of data.", + [ + 'total' => count($requests), + 'time' => [ + 'start' => $start, + 'end' => $end, + 'duration' => $end->getTimestamp() - $start->getTimestamp(), + ], + 'memory' => [ + 'now' => getMemoryUsage(), + 'peak' => getPeakMemoryUsage(), + ], + 'responses' => [ + 'size' => fsize((int)Message::get('response.size', 0)), + ], + ] + ); $this->logger->notice("Export mode ended for '{backends}'.", [ 'backends' => implode(', ', array_keys($backends)), diff --git a/src/Commands/State/ImportCommand.php b/src/Commands/State/ImportCommand.php index f4135ab1..7f96019f 100644 --- a/src/Commands/State/ImportCommand.php +++ b/src/Commands/State/ImportCommand.php @@ -381,8 +381,6 @@ class ImportCommand extends Command ], ]); - $this->db->singleTransaction(); - foreach ($list as $name => &$backend) { $metadata = false; $opts = ag($backend, 'options', []); @@ -472,21 +470,24 @@ class ImportCommand extends Command $end = makeDate(); - $this->logger->notice("SYSTEM: Completed waiting on '{total}' requests in '{time.duration}s'.", [ - 'total' => number_format(count($queue)), - 'time' => [ - 'start' => $start, - 'end' => $end, - 'duration' => $end->getTimestamp() - $start->getTimestamp(), - ], - 'memory' => [ - 'now' => getMemoryUsage(), - 'peak' => getPeakMemoryUsage(), - ], - 'responses' => [ - 'size' => fsize((int)Message::get('response.size', 0)), - ], - ]); + $this->logger->notice( + "SYSTEM: Completed waiting on '{total}' requests in '{time.duration}'s. Parsed '{responses.size}' of data.", + [ + 'total' => number_format(count($queue)), + 'time' => [ + 'start' => $start, + 'end' => $end, + 'duration' => $end->getTimestamp() - $start->getTimestamp(), + ], + 'memory' => [ + 'now' => getMemoryUsage(), + 'peak' => getPeakMemoryUsage(), + ], + 'responses' => [ + 'size' => fsize((int)Message::get('response.size', 0)), + ], + ] + ); $queue = $requestData = null; diff --git a/src/Libs/DataUtil.php b/src/Libs/DataUtil.php index a12f3e32..09b9f7c6 100644 --- a/src/Libs/DataUtil.php +++ b/src/Libs/DataUtil.php @@ -54,6 +54,11 @@ final readonly class DataUtil implements JsonSerializable, Stringable return new self(array_map($callback, $this->data)); } + public function filter(callable $callback): self + { + return new self(array_filter($this->data, $callback, ARRAY_FILTER_USE_BOTH)); + } + public function with(string $key, mixed $value): self { return new self(ag_set($this->data, $key, $value)); @@ -71,6 +76,6 @@ final readonly class DataUtil implements JsonSerializable, Stringable public function __toString(): string { - return json_encode($this->data); + return json_encode($this->jsonSerialize()); } } diff --git a/src/Libs/Database/DBLayer.php b/src/Libs/Database/DBLayer.php index 85d5f3ba..349f804d 100644 --- a/src/Libs/Database/DBLayer.php +++ b/src/Libs/Database/DBLayer.php @@ -6,13 +6,13 @@ declare(strict_types=1); namespace App\Libs\Database; use App\Libs\Exceptions\DBLayerException; +use App\Libs\Exceptions\RuntimeException; use Closure; use PDO; use PDOException; use PDOStatement; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareTrait; -use RuntimeException; final class DBLayer implements LoggerAwareInterface { @@ -20,6 +20,8 @@ final class DBLayer implements LoggerAwareInterface private const int LOCK_RETRY = 4; + private int $retry; + private int $count = 0; private string $driver; @@ -53,128 +55,207 @@ final class DBLayer implements LoggerAwareInterface public const string IS_JSON_EXTRACT = 'JSON_EXTRACT'; public const string IS_JSON_SEARCH = 'JSON_SEARCH'; - public function __construct(private PDO $pdo) + public function __construct(private readonly PDO $pdo, private array $options = []) { $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); if (is_string($driver)) { $this->driver = $driver; } + + $this->retry = ag($this->options, 'retry', self::LOCK_RETRY); } + /** + * Execute a SQL statement and return the number of affected rows. + * The execution will be wrapped into {@link DBLayer::wrap()} method. to handle database locks. + * + * @param string $sql The SQL statement to execute. + * @param array $options An optional array of options to be passed to the callback function. + * + * @return int|false The number of affected rows, or false on failure. + */ public function exec(string $sql, array $options = []): int|false { - try { - return $this->wrap(function (DBLayer $db) use ($sql, $options) { - $queryString = $sql; + $opts = []; - $this->last = [ - 'sql' => $queryString, - 'bind' => [], - ]; - - return $db->pdo->exec($queryString); - }); - } catch (PDOException $e) { - if ($e instanceof DBLayerException) { - throw $e; - } - - 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); + if (true === ag_exists($options, 'on_failure')) { + $opts['on_failure'] = $options['on_failure']; } + + return $this->wrap(function (DBLayer $db) use ($sql) { + $queryString = $sql; + + $this->last = [ + 'sql' => $queryString, + 'bind' => [], + ]; + + return $db->pdo->exec($queryString); + }, $opts); } - public function query(string|PDOStatement $sql, array $bind = [], array $options = []): PDOStatement + /** + * Execute an SQL statement and return the PDOStatement object. + * + * @param PDOStatement|string $sql The SQL statement to execute. + * @param array $bind The bind parameters for the SQL statement. + * @param array $options An optional array of options to be passed to the callback function. + * + * @return PDOStatement The returned results wrapped in a PDOStatement object. + */ + public function query(PDOStatement|string $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(), (int)$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); + $opts = []; + if (true === ag_exists($options, 'on_failure')) { + $opts['on_failure'] = $options['on_failure']; } + + return $this->wrap(function (DBLayer $db) use ($sql, $bind) { + $isStatement = $sql instanceof PDOStatement; + $queryString = $isStatement ? $sql->queryString : $sql; + + $this->last = [ + 'sql' => $queryString, + 'bind' => $bind, + ]; + + $stmt = $isStatement ? $sql : $db->prepare($sql); + + if (!empty($bind)) { + array_map( + fn($k, $v) => $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR), + array_keys($bind), + $bind + ); + } + + $stmt->execute(); + + return $stmt; + }, $opts); } + /** + * Start a transaction. + * + * @return bool Returns true on success, false on failure. + */ public function start(): bool { - if ($this->pdo->inTransaction()) { + if (true === $this->pdo->inTransaction()) { return false; } return $this->pdo->beginTransaction(); } + /** + * Commit a transaction. + * + * @return bool Returns true on success, false on failure. + */ public function commit(): bool { return $this->pdo->commit(); } + /** + * Rollback a transaction. + * + * @return bool Returns true on success, false on failure. + */ public function rollBack(): bool { return $this->pdo->rollBack(); } + /** + * Checks if inside a transaction. + * + * @return bool Returns true if a transaction is currently active, false otherwise. + */ public function inTransaction(): bool { return $this->pdo->inTransaction(); } /** - * @return bool - * @deprecated Use {@link self::start()} instead. + * This method wraps db operations in a single transaction. + * + * @param Closure $callback The callback function to be executed. + * @param bool $auto (Optional) Whether to automatically start and commit the transaction. + * @param array $options (Optional) An optional array of options to be passed the wrapper. + * + * @return mixed The result of the callback function. */ - public function beginTransaction(): bool + public function transactional(Closure $callback, bool $auto = true, array $options = []): mixed { - return $this->start(); + $autoStartTransaction = true === $auto && false === $this->inTransaction(); + + if ($autoStartTransaction) { + $options['on_failure'] = function ($e) { + if ($this->inTransaction()) { + $this->rollBack(); + } + throw $e; + }; + } + + return $this->wrap(function (DBLayer $db, array $options = []) use ($callback, $autoStartTransaction) { + if (true === $autoStartTransaction) { + $db->start(); + } + + $result = $callback($this, $options); + + if (true === $autoStartTransaction) { + $db->commit(); + } + + $this->last = $db->getLastStatement(); + return $result; + }, $options); } - public function prepare(string $sql, array $options = []): PDOStatement|false + /** + * Prepare a statement for execution and return a PDOStatement object. + * + * @param string $sql The SQL statement to prepare. + * @param array $options holds options for {@link PDOStatement} options. + * + * @return PDOStatement The returned results wrapped in a PDOStatement object. + */ + public function prepare(string $sql, array $options = []): PDOStatement { - return $this->pdo->prepare($sql, $options); + $stmt = $this->pdo->prepare($sql, $options); + + if (false === ($stmt instanceof PDOStatement)) { + throw new PDOException('Unable to prepare statement.'); + } + + return $stmt; } + /** + * Returns the ID of the last inserted row or sequence value. + * + * @return string|false return the last insert id or false on failure or not supported. + */ public function lastInsertId(): string|false { return $this->pdo->lastInsertId(); } + /** + * Delete Statement. + * + * @param string $table The table name. + * @param array $conditions The conditions to be met. i.e. WHERE clause. + * @param array $options The options to be passed to the query method. + * + * @return PDOStatement The returned results wrapped in a PDOStatement object. + */ public function delete(string $table, array $conditions, array $options = []): PDOStatement { if (empty($conditions)) { @@ -188,9 +269,13 @@ final class DBLayer implements LoggerAwareInterface $query[] = 'DELETE FROM ' . $this->escapeIdentifier($table, true) . ' WHERE'; $query[] = implode(' AND ', $cond['query']); - if (array_key_exists('limit', $options)) { + // -- For some reason, the SQLite authors don't include this feature in the amalgamation. + // So it's effectively unavailable to most third-party drivers and programs that use the amalgamation + // to compile SQLite. As we don't control the build version of SQLite, we can't guarantee that this + // feature is available. So we'll just skip it for SQLite. + $ignoreSafety = 'sqlite' !== $this->driver || true === (bool)ag($options, 'ignore_safety', false); + if (array_key_exists('limit', $options) && $ignoreSafety) { $_ = $this->limitExpr($options['limit']); - $query[] = $_['query']; $bind = array_replace_recursive($bind, $_['bind']); } @@ -277,7 +362,16 @@ final class DBLayer implements LoggerAwareInterface return $this->query(implode(' ', $query), $bind, $options); } - public function getCount(string $table, array $conditions = [], array $options = []): void + /** + * Get the count of rows in a table. + * + * @param string $table The table name. + * @param array $conditions The conditions to be met. i.e. WHERE clause. + * @param array $options The options to be passed to the query method. + * + * @return int The number of rows based on the conditions. + */ + public function getCount(string $table, array $conditions = [], array $options = []): int { $bind = $query = []; @@ -308,8 +402,20 @@ final class DBLayer implements LoggerAwareInterface } $this->count = (int)$this->query(implode(' ', $query), $bind, $options)->fetchColumn(); + + return $this->count; } + /** + * Update Statement. + * + * @param string $table The table name. + * @param array $changes The changes to be made. i.e. SET clause. + * @param array $conditions The conditions to be met. i.e. WHERE clause. + * @param array $options The options to be passed to the query method. + * + * @return PDOStatement The returned results wrapped in a PDOStatement object. + */ public function update(string $table, array $changes, array $conditions, array $options = []): PDOStatement { if (empty($changes)) { @@ -341,7 +447,12 @@ final class DBLayer implements LoggerAwareInterface $query[] = 'WHERE ' . implode(' AND ', $cond['query']); - if (array_key_exists('limit', $options)) { + // -- For some reason, the SQLite authors don't include this feature in the amalgamation. + // So it's effectively unavailable to most third-party drivers and programs that use the amalgamation + // to compile SQLite. As we don't control the build version of SQLite, we can't guarantee that this + // feature is available. So we'll just skip it for SQLite. + $ignoreSafety = 'sqlite' !== $this->driver || true === (bool)ag($options, 'ignore_safety', false); + if (array_key_exists('limit', $options) && $ignoreSafety) { $_ = $this->limitExpr((int)$options['limit']); $query[] = $_['query']; @@ -357,6 +468,15 @@ final class DBLayer implements LoggerAwareInterface return $this->query(implode(' ', $query), $bind, $options); } + /** + * Insert Statement. + * + * @param string $table The table name. + * @param array $conditions Simple associative array of [column => value]. + * @param array $options The options to be passed to the query method. + * + * @return PDOStatement The returned results wrapped in a PDOStatement object. + */ public function insert(string $table, array $conditions, array $options = []): PDOStatement { if (empty($conditions)) { @@ -387,31 +507,40 @@ final class DBLayer implements LoggerAwareInterface return $this->query($queryString, $conditions, $options); } + /** + * Quote a string for use in a query. + * + * @param mixed $text The string to be quoted. + * @param int $type Provides a data type hint for drivers that have alternate quoting styles. + * + * @return string The quoted string. + */ public function quote(mixed $text, int $type = PDO::PARAM_STR): string { return (string)$this->pdo->quote($text, $type); } - public function escape(string $text): string - { - return mb_substr($this->quote($text), 1, -1, 'UTF-8'); - } - + /** + * Get the ID generated in the last query. + * + * @param string|null $name The name of the sequence object from which the ID should be returned. + * @return string The generated ID, or empty string if no ID was generated. + */ public function id(string|null $name = null): string { return false !== ($id = $this->pdo->lastInsertId($name)) ? $id : ''; } + /** + * Get the number of rows by using {@link DBLayer::getCount()}. + * + * @return int The total number of rows. + */ public function totalRows(): int { return $this->count; } - public function close(): bool - { - return true; - } - /** * Make sure only valid characters make it in column/table names * @@ -456,11 +585,21 @@ final class DBLayer implements LoggerAwareInterface return $text; } + /** + * Get the PDO driver name. + * + * @return string The driver name. + */ public function getDriver(): string { return $this->driver; } + /** + * Get reference to the PDO object. + * + * @return PDO The PDO object. + */ public function getBackend(): PDO { return $this->pdo; @@ -617,10 +756,22 @@ final class DBLayer implements LoggerAwareInterface } $eBindName = '__db_ftS_' . random_int(1, 1000); - $keys[] = sprintf( - "MATCH(%s) AGAINST(%s)", - implode(', ', array_map(fn($columns) => $this->escapeIdentifier($columns, true), $opt[1])), - ':' . $eBindName + + $keys[] = str_replace( + ['(column)', '(bind)', '(expr)'], + [ + $eColumnName, + $eBindName, + implode(', ', array_map(fn($columns) => $this->escapeIdentifier($columns, true), $opt[1])) + ], + (function ($driver) { + if ('sqlite' === $driver) { + return "(column) MATCH :(bind)"; + } + return "MATCH((expr)) AGAINST(:(bind))"; + })( + $this->driver + ) ); $bind[$eBindName] = $opt[2]; @@ -643,7 +794,7 @@ final class DBLayer implements LoggerAwareInterface break; case self::IS_JSON_EXTRACT: if (!isset($opt[1], $opt[2], $opt[3])) { - throw new RuntimeException('IS_JSON_CONTAINS: expects 3 parameters.'); + throw new RuntimeException('IS_JSON_EXTRACT: expects 3 parameters.'); } $eBindName = '__db_je_' . random_int(1, 1000); @@ -651,7 +802,7 @@ final class DBLayer implements LoggerAwareInterface $keys[] = sprintf( "JSON_EXTRACT(%s, %s) %s %s", $this->escapeIdentifier($column, true), - $opt[1], + $this->escapeIdentifier($opt[1], true), $opt[2], ':' . $eBindName, ); @@ -672,6 +823,14 @@ final class DBLayer implements LoggerAwareInterface ]; } + /** + * Create an IN expression. + * + * @param string $key column name + * @param array $parameters array of values. + * + * @return array{bind: array, query: string}, The bind and query. + */ private function inExpr(string $key, array $parameters): array { $i = 0; @@ -689,6 +848,13 @@ final class DBLayer implements LoggerAwareInterface ]; } + /** + * Create a GROUP BY expression. + * + * @param array $groupBy The columns to group by. + * + * @return array{query: string} The query. + */ private function groupByExpr(array $groupBy): array { $groupBy = array_map( @@ -699,6 +865,13 @@ final class DBLayer implements LoggerAwareInterface return ['query' => 'GROUP BY ' . implode(', ', $groupBy)]; } + /** + * Create an ORDER BY expression. + * + * @param array $orderBy The columns to order by. + * + * @return array{query: string} The query. + */ private function orderByExpr(array $orderBy): array { $sortBy = []; @@ -712,6 +885,14 @@ final class DBLayer implements LoggerAwareInterface return ['query' => 'ORDER BY ' . implode(', ', $sortBy)]; } + /** + * Create a LIMIT expression. + * + * @param int $limit The limit. + * @param int|null $start The start. + * + * @return array{bind: array, query: string} The bind and query. + */ private function limitExpr(int $limit, ?int $start = null): array { $bind = [ @@ -732,87 +913,85 @@ final class DBLayer implements LoggerAwareInterface ]; } + /** + * Get the last executed statement. + * + * @return array The last executed statement. + */ public function getLastStatement(): array { return $this->last; } - public function transactional(Closure $callback): mixed + /** + * Wraps the given callback function with a retry mechanism to handle database locks. + * + * @param Closure{DBLayer,array} $callback The callback function to be executed. + * @param array $options An optional array of options to be passed to the callback function. + * + * @return mixed The result of the callback function. + * + * @throws DBLayerException If an error occurs while executing the callback function. + */ + private function wrap(Closure $callback, array $options = []): mixed { - $autoStartTransaction = false === $this->inTransaction(); - - for ($i = 1; $i <= self::LOCK_RETRY; $i++) { - try { - if (true === $autoStartTransaction) { - $this->start(); - } - - $result = $callback($this); - - if (true === $autoStartTransaction) { - $this->commit(); - } - - $this->last = $this->getLastStatement(); - - return $result; - } catch (DBLayerException $e) { - /** @noinspection PhpConditionAlreadyCheckedInspection */ - if ($autoStartTransaction && $this->inTransaction()) { - $this->rollBack(); - } - - //-- sometimes sqlite is locked, therefore attempt to sleep until it's unlocked. - if (false !== stripos($e->getMessage(), 'database is locked')) { - // throw exception if happens self::LOCK_RETRY times in a row. - if ($i >= self::LOCK_RETRY) { - throw $e; - } - /** @noinspection PhpUnhandledExceptionInspection */ - sleep(self::LOCK_RETRY + random_int(1, 3)); - } else { - throw $e; - } - } + static $lastFailure = []; + $on_lock = ag($options, 'on_lock', null); + $errorHandler = ag($options, 'on_failure', null); + $exception = null; + if (false === ag_exists($options, 'attempt')) { + $options['attempt'] = 0; } - /** - * We return in try or throw exception. - * As such this return should never be reached. - */ - 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(), (int)$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 { + try { + return $callback($this, $options); + } catch (PDOException $e) { + $attempts = (int)ag($options, 'attempts', 0); + if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) { + if ($attempts >= $this->retry) { throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e)) - ->setInfo($stnt->queryString, $bind, $e->errorInfo ?? [], $e->getCode()) ->setFile($e->getFile()) ->setLine($e->getLine()); } + + $sleep = (int)ag($options, 'max_sleep', rand(1, 4)); + + $this->logger?->warning("PDOAdapter: Database is locked. sleeping for '{sleep}s'.", [ + 'sleep' => $sleep + ]); + + $options['attempts'] = $attempts + 1; + if (null !== $on_lock) { + return $on_lock($e, $callback, $options); + } + + sleep($sleep); + + return $this->wrap($callback, $options); + } else { + $exception = $e; + if (null !== $errorHandler && ag($lastFailure, 'message') !== $e->getMessage()) { + $lastFailure = [ + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + 'time' => time(), + ]; + return $errorHandler($e, $callback, $options); + } + + if ($e instanceof DBLayerException) { + throw $e; + } + + throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e)) + ->setInfo($this->last['sql'], $this->last['bind'], $e->errorInfo ?? [], $e->getCode()) + ->setFile($e->getFile()) + ->setLine($e->getLine()); + } + } finally { + if (null === $exception) { + $lastFailure = []; } } - - return false; } } diff --git a/src/Libs/Database/DatabaseInterface.php b/src/Libs/Database/DatabaseInterface.php index 0099ea49..cb8a7aa4 100644 --- a/src/Libs/Database/DatabaseInterface.php +++ b/src/Libs/Database/DatabaseInterface.php @@ -179,13 +179,6 @@ interface DatabaseInterface */ public function getDBLayer(): DBLayer; - /** - * Enable single transaction mode. - * - * @return bool - */ - public function singleTransaction(): bool; - /** * Wrap queries into single transaction. * diff --git a/src/Libs/Database/PDO/PDOAdapter.php b/src/Libs/Database/PDO/PDOAdapter.php index f051c28e..b8c0c7de 100644 --- a/src/Libs/Database/PDO/PDOAdapter.php +++ b/src/Libs/Database/PDO/PDOAdapter.php @@ -9,16 +9,14 @@ use App\Libs\Database\DatabaseInterface as iDB; use App\Libs\Database\DBLayer; use App\Libs\Entity\StateInterface as iState; use App\Libs\Exceptions\DBAdapterException as DBException; -use App\Libs\Exceptions\DBLayerException; use App\Libs\Options; use Closure; use DateTimeInterface; use PDO; use PDOException; use PDOStatement; -use Psr\Log\LoggerInterface; -use Random\RandomException; -use RuntimeException; +use Psr\Log\LoggerInterface as iLogger; +use Throwable; /** * Class PDOAdapter @@ -27,21 +25,11 @@ use RuntimeException; */ final class PDOAdapter implements iDB { - /** - * @var int The number of times to retry acquiring a lock. - */ - private const int LOCK_RETRY = 4; - /** * @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. */ @@ -55,20 +43,14 @@ final class PDOAdapter implements iDB 'update' => null, ]; - /** - * @var string The database driver to be used. - */ - private string $driver = 'sqlite'; - /** * Creates a new instance of the class. * - * @param LoggerInterface $logger The logger object used for logging. + * @param iLogger $logger The logger object used for logging. * @param DBLayer $db The PDO object used for database connections. */ - public function __construct(private LoggerInterface $logger, private readonly DBLayer $db) + public function __construct(private iLogger $logger, private readonly DBLayer $db) { - $this->driver = $this->db->getDriver(); } /** @@ -83,7 +65,6 @@ final class PDOAdapter implements iDB /** * @inheritdoc - * @throws RandomException if an error occurs while generating a random number. */ public function insert(iState $entity): iState { @@ -158,12 +139,20 @@ final class PDOAdapter implements iDB ); } - $this->execute($this->stmt['insert'], $data); + $this->db->query($this->stmt['insert'], $data, options: [ + 'on_failure' => function (Throwable $e) use ($entity) { + if (false === str_contains($e->getMessage(), '21 bad parameter or other API misuse')) { + throw $e; + } + $this->stmt['insert'] = null; + return $this->insert($entity); + } + ]); $entity->id = (int)$this->db->lastInsertId(); } catch (PDOException $e) { $this->stmt['insert'] = null; - if (false === $this->viaTransaction && false === $this->singleTransaction) { + if (false === $this->viaTransaction) { $this->logger->error( message: "PDOAdapter: Exception '{error.kind}' was thrown unhandled. '{error.message}' at '{error.file}:{error.line}'.", context: [ @@ -193,7 +182,6 @@ final class PDOAdapter implements iDB /** * @inheritdoc - * @throws RandomException if an error occurs while generating a random number. */ public function get(iState $entity): iState|null { @@ -204,19 +192,7 @@ final class PDOAdapter implements iDB } if (null !== $entity->id) { - $stmt = $this->query( - r( - 'SELECT * FROM state WHERE ${column} = ${id}', - context: [ - 'column' => iState::COLUMN_ID, - 'id' => (int)$entity->id - ], - opts: [ - 'tag_left' => '${', - 'tag_right' => '}' - ], - ) - ); + $stmt = $this->db->query('SELECT * FROM state WHERE id = :id', ['id' => (int)$entity->id]); if (false !== ($item = $stmt->fetch(PDO::FETCH_ASSOC))) { $item = $entity::fromArray($item); @@ -247,7 +223,6 @@ final class PDOAdapter implements iDB /** * @inheritdoc - * @throws RandomException if an error occurs while generating a random number. */ public function getAll(DateTimeInterface|null $date = null, array $opts = []): array { @@ -271,13 +246,14 @@ final class PDOAdapter implements iDB $sql .= ' WHERE ' . iState::COLUMN_UPDATED . ' > ' . $date->getTimestamp(); } - if (null === ($opts['class'] ?? null) || false === ($opts['class'] instanceof iState)) { + $fromClass = $opts['class'] ?? $this->options['class'] ?? null; + if (null === ($fromClass ?? null) || false === ($fromClass instanceof iState)) { $class = Container::get(iState::class); } else { - $class = $opts['class']; + $class = $fromClass; } - foreach ($this->query($sql) as $row) { + foreach ($this->db->query($sql) as $row) { $arr[] = $class::fromArray($row); } @@ -286,7 +262,6 @@ final class PDOAdapter implements iDB /** * @inheritdoc - * @throws RandomException if an error occurs while generating a random number. */ public function find(iState ...$items): array { @@ -305,13 +280,11 @@ final class PDOAdapter implements iDB /** * @inheritdoc - * @throws RandomException */ public function findByBackendId(string $backend, int|string $id, string|null $type = null): iState|null { $key = $backend . '.' . iState::COLUMN_ID; $cond = [ - 'id' => $id ]; $type_sql = ''; @@ -320,39 +293,33 @@ final class PDOAdapter implements iDB $cond['type'] = $type; } - $sql = "SELECT * FROM state WHERE {$type_sql} JSON_EXTRACT(" . iState::COLUMN_META_DATA . ",'$.{$key}') = :id LIMIT 1"; - $stmt = $this->db->prepare($sql); - - if (false === $this->execute($stmt, $cond)) { - throw new DBException( - r("PDOAdapter: Failed to execute sql query. Statement '{sql}', Conditions '{cond}'.", [ - 'sql' => $sql, - 'cond' => arrayToString($cond), - ]), 61 - ); - } + $sql = "SELECT * FROM state WHERE {$type_sql} JSON_EXTRACT(" . iState::COLUMN_META_DATA . ",'$.{$key}') = {id} LIMIT 1"; + $stmt = $this->db->query(r($sql, ['id' => is_int($id) ? $id : $this->db->quote($id)]), $cond); if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) { return null; } - return Container::get(iState::class)::fromArray($row); - } + $fromClass = $this->options['class'] ?? null; + if (null === ($fromClass ?? null) || false === ($fromClass instanceof iState)) { + $class = Container::get(iState::class); + } else { + $class = $fromClass; + } + return $class::fromArray($row); + } /** * @inheritdoc - * @throws RandomException if an error occurs while generating a random number. */ public function update(iState $entity): iState { try { if (null === ($entity->id ?? null)) { - throw new DBException( - r("PDOAdapter: Unable to update '{title}' without primary key defined.", [ - 'title' => $entity->getName() ?? 'Unknown' - ]), 51 - ); + throw new DBException(r("PDOAdapter: Unable to update '{title}' without primary key defined.", [ + 'title' => $entity->getName() ?? 'Unknown' + ]), 51); } if (true === $entity->isEpisode() && $entity->episode < 1) { @@ -391,15 +358,21 @@ final class PDOAdapter implements iDB } if (null === ($this->stmt['update'] ?? null)) { - $this->stmt['update'] = $this->db->prepare( - $this->pdoUpdate('state', iState::ENTITY_KEYS) - ); + $this->stmt['update'] = $this->db->prepare($this->pdoUpdate('state', iState::ENTITY_KEYS)); } - $this->execute($this->stmt['update'], $data); + $this->db->query($this->stmt['update'], $data, options: [ + 'on_failure' => function (Throwable $e) use ($entity) { + if (false === str_contains($e->getMessage(), '21 bad parameter or other API misuse')) { + throw $e; + } + $this->stmt['update'] = null; + return $this->update($entity); + } + ]); } catch (PDOException $e) { $this->stmt['update'] = null; - if (false === $this->viaTransaction && false === $this->singleTransaction) { + if (false === $this->viaTransaction) { $this->logger->error( message: "PDOAdapter: Exception '{error.kind}' was thrown unhandled. '{error.message}' at '{error.file}:{error.line}'.", context: [ @@ -429,7 +402,6 @@ final class PDOAdapter implements iDB /** * @inheritdoc - * @throws RandomException if an error occurs while generating a random number. */ public function remove(iState $entity): bool { @@ -447,19 +419,7 @@ final class PDOAdapter implements iDB $id = $entity->id; } - $this->query( - r( - 'DELETE FROM state WHERE ${column} = ${id}', - [ - 'column' => iState::COLUMN_ID, - 'id' => (int)$id - ], - opts: [ - 'tag_left' => '${', - 'tag_right' => '}' - ] - ) - ); + $this->db->query('DELETE FROM state WHERE id = :id', ['id' => (int)$id]); } catch (PDOException $e) { $this->logger->error( message: "PDOAdapter: Exception '{error.kind}' was thrown unhandled. '{error.message}' at '{error.file}:{error.line}'.", @@ -488,7 +448,6 @@ final class PDOAdapter implements iDB /** * @inheritdoc - * @throws RandomException if an error occurs while generating a random number. */ public function commit(array $entities, array $opts = []): array { @@ -563,7 +522,7 @@ final class PDOAdapter implements iDB /** * @inheritdoc */ - public function migrateData(string $version, LoggerInterface|null $logger = null): mixed + public function migrateData(string $version, iLogger|null $logger = null): mixed { return (new PDODataMigration($this->db, $logger ?? $this->logger))->automatic(); } @@ -618,7 +577,7 @@ final class PDOAdapter implements iDB /** * @inheritdoc */ - public function setLogger(LoggerInterface $logger): iDB + public function setLogger(iLogger $logger): iDB { $this->logger = $logger; @@ -630,20 +589,6 @@ final class PDOAdapter implements iDB return $this->db; } - /** - * @inheritdoc - */ - public function singleTransaction(): bool - { - $this->singleTransaction = true; - - if (false === $this->db->inTransaction()) { - $this->db->start(); - } - - return $this->db->inTransaction(); - } - /** * @inheritdoc */ @@ -684,7 +629,7 @@ final class PDOAdapter implements iDB */ public function __destruct() { - if (true === $this->singleTransaction && true === $this->db->inTransaction()) { + if (true === $this->db->inTransaction()) { $this->db->commit(); } @@ -755,7 +700,6 @@ final class PDOAdapter implements iDB * @param iState $entity Entity get external ids from. * * @return iState|null Entity if found, null otherwise. - * @throws RandomException if an error occurs while generating a random number. */ private function findByExternalId(iState $entity): iState|null { @@ -809,17 +753,7 @@ final class PDOAdapter implements iDB $sqlGuids = ' AND ( ' . implode(' OR ', $guids) . ' ) '; $sql = "SELECT * FROM state WHERE " . iState::COLUMN_TYPE . " = :type {$sqlEpisode} {$sqlGuids} LIMIT 1"; - - $stmt = $this->db->prepare($sql); - - if (false === $this->execute($stmt, $cond)) { - throw new DBException( - r("PDOAdapter: Failed to execute sql query. Statement '{sql}', Conditions '{cond}'.", [ - 'sql' => $sql, - 'cond' => arrayToString($cond), - ]), 61 - ); - } + $stmt = $this->db->query($sql, $cond); if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) { return null; @@ -827,150 +761,4 @@ final class PDOAdapter implements iDB return $entity::fromArray($row); } - - /** - * Executes a prepared SQL statement with optional parameters. - * - * @param PDOStatement $stmt The prepared statement to execute. - * @param array $cond An optional array of parameters to bind to the statement. - * @return bool True if the statement was successfully executed, false otherwise. - * - * @throws PDOException if an error occurs during the execution of the statement. - * @throws RandomException if an error occurs while generating a random number. - */ - private function execute(PDOStatement $stmt, array $cond = []): bool - { - return $this->wrap(fn(PDOAdapter $adapter) => $stmt->execute($cond)); - } - - /** - * Executes a SQL query on the database. - * - * @param string $sql The SQL query to be executed. - * - * @return PDOStatement|false The result of the query as a PDOStatement object. - * It will return false if the query fails. - * - * @throws PDOException If an error occurs while executing the query. - * @throws RandomException If an error occurs while generating a random number. - */ - private function query(string $sql): PDOStatement|false - { - return $this->wrap(fn(PDOAdapter $adapter) => $adapter->db->query($sql)); - } - - /** - * FOR DEBUGGING AND DISPLAY PURPOSES ONLY. - * - * @note Do not use it for anything. - * @param string $sql - * @param array $parameters - * @return string - * - * @internal This is for debugging purposes only. - */ - public function getRawSQLString(string $sql, array $parameters): string - { - $replacer = []; - - foreach ($parameters as $key => $val) { - $replacer['/(\:' . preg_quote($key, '/') . ')(?:\b|\,)/'] = ctype_digit( - (string)$val - ) ? (int)$val : '"' . $val . '"'; - } - - return preg_replace(array_keys($replacer), array_values($replacer), $sql); - } - - /** - * Generates a valid identifier for a table or column. - * - * @param string $text The input text to be transformed into a valid identifier. - * @param bool $quote Indicates whether the generated identifier should be quoted. - * By default, it is set to true. - * - * @return string The generated identifier. - * @throws RuntimeException If the input text is not a valid ASCII name or does not meet the naming convention requirements. - */ - public function identifier(string $text, bool $quote = true): string - { - // table or column has to be valid ASCII name. - // this is opinionated, but we only allow [a-zA-Z0-9_] in column/table name. - if (!\preg_match('#\w#', $text)) { - throw new RuntimeException( - r("PDOAdapter: Invalid column/table '{ident}'. Column/table must be valid ASCII code.", [ - 'ident' => $text - ]) - ); - } - - // The first character cannot be [0-9]: - if (\preg_match('/^\d/', $text)) { - throw new RuntimeException( - r("PDOAdapter: Invalid column/table '{ident}'. Must begin with a letter or underscore.", [ - 'ident' => $text - ] - ) - ); - } - - return !$quote ? $text : match ($this->driver) { - 'mssql' => '[' . $text . ']', - 'mysql' => '`' . $text . '`', - 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(), (int)$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(), (int)$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/EnvFile.php b/src/Libs/EnvFile.php index 9e6f88f3..c86655f2 100644 --- a/src/Libs/EnvFile.php +++ b/src/Libs/EnvFile.php @@ -28,6 +28,19 @@ final class EnvFile $this->data = parseEnvFile($this->file); } + /** + * Return a new instance of the class with the same file. + * This method will not flush the current data into new instance. + * You must call persist() method to save the data. before calling this method. + * + * @param bool $create + * @return self + */ + public function newInstance(bool $create = false): self + { + return new self($this->file, create: $create); + } + /** * Get the value of a configuration setting. * diff --git a/src/Libs/Router.php b/src/Libs/Router.php deleted file mode 100644 index 3b37d772..00000000 --- a/src/Libs/Router.php +++ /dev/null @@ -1,212 +0,0 @@ -dirs; - } - - public function generate(): array - { - $routes = []; - - foreach ($this->dirs as $path) { - array_push($routes, ...$this->scanDirectory($path)); - } - - usort($routes, fn($a, $b) => strlen($a['path']) < strlen($b['path']) ? -1 : 1); - - return $routes; - } - - private function scanDirectory(string $dir): array - { - $classes = $routes = []; - - /** @var array $files */ - $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS) - ); - - foreach ($files as $file) { - if (!$file->isFile()) { - continue; - } - - if (!$file->isReadable() || 'php' !== $file->getExtension()) { - continue; - } - - $class = $this->parseFile((string)$file); - - if (false === $class) { - continue; - } - - array_push($classes, ...$class); - } - - foreach ($classes as $className) { - if (!class_exists($className)) { - continue; - } - - array_push($routes, ...$this->getRoutes(new ReflectionClass($className))); - } - - return $routes; - } - - protected function getRoutes(ReflectionClass $class): array - { - $routes = []; - - $attributes = $class->getAttributes(Route::class, ReflectionAttribute::IS_INSTANCEOF); - - $invokable = false; - - foreach ($class->getMethods() as $method) { - if ($method->getName() === '__invoke') { - $invokable = true; - } - } - - foreach ($attributes as $attribute) { - try { - $attributeClass = $attribute->newInstance(); - - if (!$attributeClass instanceof Route) { - continue; - } - } catch (Throwable) { - continue; - } - - if (false === $invokable && !$attributeClass->isCli) { - throw new InvalidArgumentException( - r( - 'Trying to route \'{route}\' to un-invokable class/method \'{callable}\'.', - [ - 'route' => $attributeClass->pattern, - 'callable' => $class->getName() - ] - ) - ); - } - - $routes[] = [ - 'path' => $attributeClass->pattern, - 'method' => $attributeClass->methods, - 'callable' => $class->getName(), - 'host' => $attributeClass->host, - 'middlewares' => $attributeClass->middleware, - 'name' => $attributeClass->name, - 'port' => $attributeClass->port, - 'scheme' => $attributeClass->scheme, - ]; - } - - foreach ($class->getMethods() as $method) { - $attributes = $method->getAttributes(Route::class, ReflectionAttribute::IS_INSTANCEOF); - - foreach ($attributes as $attribute) { - try { - $attributeClass = $attribute->newInstance(); - if (!$attributeClass instanceof Route) { - continue; - } - } catch (Throwable) { - continue; - } - - $call = $method->getName() === '__invoke' ? $class->getName() : [$class->getName(), $method->getName()]; - - $routes[] = [ - 'path' => $attributeClass->pattern, - 'method' => $attributeClass->methods, - 'callable' => $call, - 'host' => $attributeClass->host, - 'middlewares' => $attributeClass->middleware, - 'name' => $attributeClass->name, - 'port' => $attributeClass->port, - 'scheme' => $attributeClass->scheme, - ]; - } - } - - return $routes; - } - - private function parseFile(string $file): array|false - { - $classes = []; - $namespace = ''; - - try { - $stream = new Stream($file, 'r'); - $content = $stream->getContents(); - $stream->close(); - } catch (InvalidArgumentException $e) { - throw new RuntimeException( - r('Unable to read \'{file}\'. {error}', [ - 'file' => $file, - 'error' => $e->getMessage(), - ]) - ); - } - - $tokens = PhpToken::tokenize($content); - $count = count($tokens); - - foreach ($tokens as $i => $iValue) { - if ($iValue->getTokenName() === 'T_NAMESPACE') { - for ($j = $i + 1; $j < $count; $j++) { - if ($tokens[$j]->getTokenName() === 'T_NAME_QUALIFIED') { - $namespace = $tokens[$j]->text; - break; - } - } - } - - if ($iValue->getTokenName() === 'T_CLASS') { - for ($j = $i + 1; $j < $count; $j++) { - if ($tokens[$j]->getTokenName() === 'T_WHITESPACE') { - continue; - } - - if ($tokens[$j]->getTokenName() === 'T_STRING') { - $classes[] = $namespace . '\\' . $tokens[$j]->text; - } else { - break; - } - } - } - } - - return count($classes) >= 1 ? $classes : false; - } -} diff --git a/src/Libs/ServeStatic.php b/src/Libs/ServeStatic.php index c1183c8f..c1157968 100644 --- a/src/Libs/ServeStatic.php +++ b/src/Libs/ServeStatic.php @@ -10,11 +10,15 @@ use League\Route\Http\Exception\BadRequestException; use League\Route\Http\Exception\NotFoundException; use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ServerRequestInterface as iRequest; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; use SplFileInfo; use Throwable; -final class ServeStatic +final class ServeStatic implements LoggerAwareInterface { + use LoggerAwareTrait; + private finfo|null $mimeType = null; private const array CONTENT_TYPE = [ @@ -39,7 +43,6 @@ final class ServeStatic private const array MD_IMAGES = [ '/screenshots' => __DIR__ . '/../../', ]; - private array $looked = []; public function __construct(private string|null $staticPath = null) { @@ -59,8 +62,6 @@ final class ServeStatic */ public function serve(iRequest $request): iResponse { - $requestPath = $request->getUri()->getPath(); - if (false === in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'])) { throw new BadRequestException( message: r("Method '{method}' is not allowed.", ['method' => $request->getMethod()]), @@ -68,6 +69,11 @@ final class ServeStatic ); } + // -- as we alter the static path for .md files, we need to keep the original path + // -- do not mutate the original path. as it may be used in other requests. + $staticPath = $this->staticPath; + $requestPath = $request->getUri()->getPath(); + if (array_key_exists($requestPath, self::MD_FILES)) { return $this->serveFile($request, new SplFileInfo(self::MD_FILES[$requestPath])); } @@ -75,44 +81,36 @@ final class ServeStatic // -- check if the request path is in the MD_IMAGES array foreach (self::MD_IMAGES as $key => $value) { if (str_starts_with($requestPath, $key)) { - $this->staticPath = realpath($value); + $staticPath = realpath($value); break; } } - $filePath = fixPath($this->staticPath . $requestPath); + if (false === ($realBasePath = realpath($staticPath))) { + throw new BadRequestException( + message: r("The static path '{path}' doesn't exists.", ['path' => $staticPath]), + code: Status::SERVICE_UNAVAILABLE->value + ); + } + $filePath = fixPath($staticPath . $requestPath); if (is_dir($filePath)) { $filePath = $filePath . '/index.html'; } if (!file_exists($filePath)) { - $checkIndex = $this->deepIndexLookup($this->staticPath, $requestPath); - if (!file_exists($checkIndex)) { - throw new NotFoundException( - message: r( - "File '{file}' is not found. {checkIndex} {looked}", - [ - 'file' => $requestPath, - 'checkIndex' => $checkIndex, - 'looked' => $this->looked, - ] - ), - code: Status::NOT_FOUND->value - ); + $this->logger?->debug("File '{file}' is not found.", ['file' => $filePath]); + $checkIndex = fixPath($staticPath . $this->deepIndexLookup($staticPath, $requestPath)); + if (false === file_exists($checkIndex) || false === is_file($checkIndex)) { + throw new NotFoundException(r("Path '{file}' is not found.", [ + 'file' => $requestPath, + ]), code: Status::NOT_FOUND->value); } $filePath = $checkIndex; } - if (false === ($realBasePath = realpath($this->staticPath))) { - throw new BadRequestException( - message: r("The static path '{path}' doesn't exists.", ['path' => $this->staticPath]), - code: Status::SERVICE_UNAVAILABLE->value - ); - } $filePath = realpath($filePath); - if (false === $filePath || false === str_starts_with($filePath, $realBasePath)) { throw new BadRequestException( message: r("Request '{file}' is invalid.", ['file' => $requestPath]), @@ -183,23 +181,21 @@ final class ServeStatic // -- paths may look like /parent/id/child, do a deep lookup for index.html at each level // return the first index.html found $path = fixPath($path); - - if ('/' === $path) { + if ('/' === $path || empty($path)) { return $path; } $paths = explode('/', $path); $count = count($paths); - if ($count < 2) { + $index = $count - 1; + + if ($index < 2) { return $path; } - $index = $count - 1; - for ($i = $index; $i > 0; $i--) { - $check = $base . implode('/', array_slice($paths, 0, $i)) . '/index.html'; - $this->looked[] = $check; - if (file_exists($check)) { + $check = implode('/', array_slice($paths, 0, $i)) . '/index.html'; + if (file_exists($base . $check)) { return $check; } } diff --git a/src/Libs/Server.php b/src/Libs/Server.php index 418ad15f..55ebf538 100644 --- a/src/Libs/Server.php +++ b/src/Libs/Server.php @@ -16,13 +16,13 @@ use Symfony\Component\Process\Process; */ final class Server { - public const CONFIG_HOST = 'host'; - public const CONFIG_PORT = 'port'; - public const CONFIG_ROOT = 'root'; - public const CONFIG_PHP = 'php'; - public const CONFIG_ENV = 'env'; - public const CONFIG_ROUTER = 'router'; - public const CONFIG_THREADS = 'threads'; + public const string CONFIG_HOST = 'host'; + public const string CONFIG_PORT = 'port'; + public const string CONFIG_ROOT = 'root'; + public const string CONFIG_PHP = 'php'; + public const string CONFIG_ENV = 'env'; + public const string CONFIG_ROUTER = 'router'; + public const string CONFIG_THREADS = 'threads'; /** * @var array $config The configuration settings for the server diff --git a/src/Libs/TestCase.php b/src/Libs/TestCase.php index 8ee72f26..80a3df94 100644 --- a/src/Libs/TestCase.php +++ b/src/Libs/TestCase.php @@ -36,6 +36,7 @@ class TestCase extends \PHPUnit\Framework\TestCase * @param Throwable|string $exception Expected exception class * @param string $exceptionMessage (optional) Exception message * @param int|null $exceptionCode (optional) Exception code + * @param callable{ TestCase, Throwable}|null $callback (optional) Custom callback to handle the exception * @return void */ protected function checkException( @@ -44,6 +45,7 @@ class TestCase extends \PHPUnit\Framework\TestCase Throwable|string $exception, string $exceptionMessage = '', int|null $exceptionCode = null, + callable $callback = null, ): void { $caught = null; try { @@ -51,13 +53,17 @@ class TestCase extends \PHPUnit\Framework\TestCase } catch (Throwable $e) { $caught = $e; } finally { + if (null !== $callback) { + $callback($this, $caught); + return; + } if (null === $caught) { - $this->fail($reason); + $this->fail('No exception was thrown. ' . $reason); } else { $this->assertInstanceOf( is_object($exception) ? $exception::class : $exception, $caught, - $reason + $reason . ' ' . $caught->getMessage(), ); if (!empty($exceptionMessage)) { $this->assertStringContainsString($exceptionMessage, $caught->getMessage(), $reason); diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index 56732ace..7ca7205a 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -7,6 +7,8 @@ use App\Backends\Common\Cache as BackendCache; use App\Backends\Common\ClientInterface as iClient; use App\Backends\Common\Context; use App\Libs\APIResponse; +use App\Libs\Attributes\Route\Cli; +use App\Libs\Attributes\Route\Route; use App\Libs\Attributes\Scanner\Attributes as AttributesScanner; use App\Libs\Attributes\Scanner\Item as ScannerItem; use App\Libs\Config; @@ -27,7 +29,6 @@ use App\Libs\Guid; use App\Libs\Initializer; use App\Libs\Options; use App\Libs\Response; -use App\Libs\Router; use App\Libs\Stream; use App\Libs\Uri; use App\Listeners\ProcessPushEvent; @@ -1021,29 +1022,53 @@ if (false === function_exists('generateRoutes')) { */ function generateRoutes(string $type = 'cli', array $opts = []): array { - $dirs = [__DIR__ . '/../Commands']; - foreach (array_keys(Config::get('supported', [])) as $backend) { - $dir = r(__DIR__ . '/../Backends/{backend}/Commands', ['backend' => ucfirst($backend)]); - - if (!file_exists($dir)) { - continue; - } - - $dirs[] = $dir; - } - - $routes_cli = (new Router($dirs))->generate(); - $cache = $opts[iCache::class] ?? Container::get(iCache::class); + $routes_cli = $routes_http = []; + try { + $dirs = [__DIR__ . '/../Commands']; + foreach (array_keys(Config::get('supported', [])) as $backend) { + $dir = r(__DIR__ . '/../Backends/{backend}/Commands', ['backend' => ucfirst($backend)]); + + if (!file_exists($dir)) { + continue; + } + + $dirs[] = $dir; + } + foreach (AttributesScanner::scan($dirs, allowNonInvokable: true)->for(Cli::class) as $item) { + $routes_cli[] = [ + 'callable' => $item->getCallable(), + 'path' => ag($item->data, 'pattern'), + 'method' => ag($item->data, 'methods'), + 'middleware' => ag($item->data, 'middleware'), + 'host' => ag($item->data, 'host'), + 'name' => ag($item->data, 'name'), + 'port' => ag($item->data, 'port'), + 'scheme' => ag($item->data, 'scheme'), + ]; + } + $cache->set('routes_cli', $routes_cli, new DateInterval('PT1H')); } catch (\Psr\SimpleCache\InvalidArgumentException) { } - $routes_http = (new Router([__DIR__ . '/../API']))->generate(); - try { + $dirs = [__DIR__ . '/../API']; + foreach (AttributesScanner::scan($dirs, allowNonInvokable: false)->for(Route::class) as $item) { + $routes_http[] = [ + 'callable' => $item->getCallable(), + 'path' => ag($item->data, 'pattern'), + 'method' => ag($item->data, 'methods'), + 'middleware' => ag($item->data, 'middleware'), + 'host' => ag($item->data, 'host'), + 'name' => ag($item->data, 'name'), + 'port' => ag($item->data, 'port'), + 'scheme' => ag($item->data, 'scheme'), + ]; + } + $cache->set('routes_http', $routes_http, new DateInterval('P1D')); } catch (\Psr\SimpleCache\InvalidArgumentException) { } diff --git a/src/Model/Base/Traits/UsesBasicRepository.php b/src/Model/Base/Traits/UsesBasicRepository.php index 934f116f..9096d176 100644 --- a/src/Model/Base/Traits/UsesBasicRepository.php +++ b/src/Model/Base/Traits/UsesBasicRepository.php @@ -15,13 +15,9 @@ trait UsesBasicRepository { use UsesPaging; - protected DBLayer $db; - - public function __construct(DBLayer $db) + public function __construct(private readonly DBLayer $db) { - $this->init($db); - - $this->db = $db; + $this->init($this->db); if (empty($this->table)) { throw new RuntimeException('You must set table name in $this->table'); diff --git a/tests/Database/DBLayerTest.php b/tests/Database/DBLayerTest.php new file mode 100644 index 00000000..f6f9ba43 --- /dev/null +++ b/tests/Database/DBLayerTest.php @@ -0,0 +1,646 @@ +exec('DROP TABLE IF EXISTS "test"'); + $pdo->exec( + <<exec('DROP TABLE IF EXISTS "fts_table"'); + $pdo->exec('CREATE VIRTUAL TABLE "fts_table" USING fts5( name, json_data);'); + } + + public function setUp(): void + { + $this->handler = new TestHandler(); + $logger = new Logger('logger'); + $logger->pushHandler($this->handler); + Guid::setLogger($logger); + + if (null === Config::get('database', null)) { + Config::init([ + 'database' => ag(require __DIR__ . '/../../config/config.php', 'database', []) + ]); + } + + $this->db = new DBLayer(new PDO(dsn: 'sqlite::memory:', options: Config::get('database.options', []))); + $this->createDB($this->db->getBackend()); + + foreach (Config::get('database.exec', []) as $cmd) { + $this->db->exec($cmd); + } + } + + public function test_exec() + { + $this->checkException( + closure: fn() => $this->db->exec('SELECT * FROM movies'), + reason: 'Should throw an exception when an error occurs and no on_failure handler is set.', + exception: DBLayerException::class, + exceptionMessage: 'no such table', + ); + + $this->checkException( + closure: fn() => $this->db->exec( + sql: 'SELECT * FROM movies', + options: ['on_failure' => fn(Throwable $e) => throw new ErrorException('Error occurred')] + ), + reason: 'the on_failure handler should be called when an error occurs.', + exception: ErrorException::class, + exceptionMessage: 'Error occurred', + ); + + $this->assertSame(0, $this->db->exec('DELETE FROM test')); + } + + public function test_query() + { + $this->checkException( + closure: fn() => $this->db->query(sql: 'SELECT * FROM movies'), + reason: 'Should throw an exception when an error occurs and no on_failure handler is set.', + exception: DBLayerException::class, + exceptionMessage: 'no such table', + ); + + $this->checkException( + closure: fn() => $this->db->query( + sql: 'SELECT * FROM movies', + options: ['on_failure' => fn(Throwable $e) => throw new ErrorException('Error occurred')] + ), + reason: 'the on_failure handler should be called when an error occurs.', + exception: ErrorException::class, + exceptionMessage: 'Error occurred', + ); + + $this->checkException( + closure: function () { + $options = Config::get('database.options', []); + $options[PDO::ATTR_ERRMODE] = PDO::ERRMODE_SILENT; + + $pdo = new PDO(dsn: 'sqlite::memory:', options: $options); + $db = new DBLayer($pdo); + + foreach (Config::get('database.exec', []) as $cmd) { + $this->db->exec($cmd); + } + + (new PDOAdapter(new Logger('test'), $this->db))->migrations('up'); + + return $db->query(sql: 'SELECT * FROM test WHERE zid = :id'); + }, + reason: 'If PDO error mode is set to silent mode, failing to prepare a statement should still throw an exception.', + exception: DBLayerException::class, + exceptionMessage: 'Unable to prepare statement.', + ); + } + + public function test_transactions_operations() + { + $this->db->start(); + $this->assertTrue($this->db->inTransaction(), 'Should be in transaction.'); + $this->assertFalse($this->db->start(), 'Should not start a new transaction if we are already in one.'); + $this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]); + $this->assertSame('1', $this->db->lastInsertId(), 'Should return last insert id.'); + $this->db->rollBack(); + $this->assertFalse($this->db->inTransaction(), 'Should not be in transaction.'); + + $this->db->start(); + $this->assertCount( + 0, + $this->db->select('sqlite_sequence')->fetchAll(), + 'Should not have any records, as we rolled back.' + ); + $this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]); + $this->assertSame('1', $this->db->lastInsertId(), 'Should return last insert id.'); + $this->db->commit(); + $this->assertFalse($this->db->inTransaction(), 'Should not be in transaction.'); + $this->assertCount( + 1, + $this->db->select('sqlite_sequence')->fetchAll(), + 'Should have one record, as we committed.' + ); + + $this->checkException( + closure: fn() => $this->db->transactional(function (DBLayer $db) { + $this->db->insert('sqlite_sequence', ['name' => 'test2', 'seq' => 1]); + $db->insert('not_set', ['name' => 'test', 'seq' => 1]); + }), + reason: 'Should throw an exception when trying to commit without starting a transaction.', + exception: DBLayerException::class, + exceptionMessage: 'no such table', + ); + + $this->assertCount( + 1, + $this->db->select('sqlite_sequence')->fetchAll(), + 'Should have one record, as the previous transaction was rolled back.' + ); + + $ret = $this->db->transactional(function (DBLayer $db) { + return $db->insert('sqlite_sequence', ['name' => 'test2', 'seq' => 1]); + }); + + $this->assertSame(1, $ret->rowCount(), 'Should return the number of affected rows.'); + } + + public function test_insert() + { + $this->checkException( + closure: fn() => $this->db->insert('test', []), + reason: 'Should throw exception if conditions parameter is empty.', + exception: RuntimeException::class + ); + } + + public function test_delete() + { + $this->checkException( + closure: fn() => $this->db->delete('test', []), + reason: 'Should throw exception if conditions parameter is empty.', + exception: RuntimeException::class + ); + + $this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]); + try { + $this->assertSame( + 1, + $this->db->delete('sqlite_sequence', ['name' => 'test'], options: [ + 'limit' => 1, + 'ignore_safety' => true + ])->rowCount(), + 'Should return the number of affected rows.' + ); + } catch (DBLayerException $e) { + if (str_contains($e->getMessage(), 'near "LIMIT": syntax error') && 'sqlite' === $this->db->getDriver()) { + $this->assertSame( + 1, + $this->db->delete('sqlite_sequence', ['name' => 'test'])->rowCount(), + 'Should return the number of affected rows.' + ); + } else { + throw $e; + } + } + } + + public function test_getCount() + { + $this->assertSame( + 0, + $this->db->getCount('sqlite_sequence'), + 'Should return the number of records in the table.' + ); + $this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]); + $total = $this->db->getCount('sqlite_sequence', [ + 'seq' => [DBLayer::IS_HIGHER_THAN_OR_EQUAL, 1] + ], options: [ + 'groupby' => ['name'], + 'orderby' => ['name' => 'ASC'], + ]); + $this->assertSame(1, $total, 'Should return the number of records in the table.'); + $this->assertSame($total, $this->db->totalRows(), 'Should return the number of records in the table.'); + + $this->db->delete('sqlite_sequence', ['name' => 'test']); + $this->assertSame( + 0, + $this->db->getCount('sqlite_sequence'), + 'Should return the number of records in the table.' + ); + } + + public function test_update() + { + $this->checkException( + closure: fn() => $this->db->update('test', [], ['id' => 1]), + reason: 'Should throw exception if conditions parameter is empty.', + exception: RuntimeException::class + ); + + $this->checkException( + closure: fn() => $this->db->update('test', ['name' => 'test'], []), + reason: 'Should throw exception if conditions parameter is empty.', + exception: RuntimeException::class + ); + + $this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]); + $this->assertSame( + 1, + $this->db->update('sqlite_sequence', ['seq' => 2], ['name' => 'test'])->rowCount(), + 'Should return the number of affected rows.' + ); + try { + $this->assertSame( + 1, + $this->db->update('sqlite_sequence', ['seq' => 1], ['name' => 'test'], options: [ + 'limit' => 1, + 'ignore_safety' => true + ])->rowCount(), + 'Should return the number of affected rows.' + ); + } catch (DBLayerException $e) { + if (str_contains($e->getMessage(), 'near "LIMIT": syntax error') && 'sqlite' === $this->db->getDriver()) { + $this->assertSame( + 1, + $this->db->update('sqlite_sequence', ['seq' => 1], ['name' => 'test'])->rowCount(), + 'Should return the number of affected rows.' + ); + } else { + throw $e; + } + } + } + + public function test_quote() + { + if ('sqlite' === $this->db->getDriver()) { + $this->assertEquals("'test'", $this->db->quote('test'), "Should return 'test'."); + $this->assertSame("'''test'''", $this->db->quote("'test'"), "Should return ''''test''''."); + $this->assertSame("'\"test\"'", $this->db->quote('"test"'), "Should return '\"test\"'."); + } + } + + public function test_id() + { + $this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]); + $this->assertSame('1', $this->db->id('test'), 'Should return the last insert id.'); + $this->assertSame('1', $this->db->id(), 'Should return the last insert id.'); + } + + public function test_escapeIdentifier() + { + $this->checkException( + closure: fn() => $this->db->escapeIdentifier(''), + reason: 'Should throw exception if the identifier is empty.', + exception: RuntimeException::class, + exceptionMessage: 'Column/table must be valid ASCII code' + ); + + $this->checkException( + closure: fn() => $this->db->escapeIdentifier('😊'), + reason: 'Should throw exception if the identifier contains non-ASCII characters.', + exception: RuntimeException::class, + exceptionMessage: 'Column/table must be valid ASCII code.' + ); + + $this->checkException( + closure: fn() => $this->db->escapeIdentifier('1foo'), + reason: 'Should throw exception if the identifier contains non-ASCII characters.', + exception: RuntimeException::class, + exceptionMessage: 'Must begin with a letter or underscore' + ); + + $this->assertSame('foo', $this->db->escapeIdentifier('foo'), 'Should return foo if quote is off.'); + + if ('sqlite' === $this->db->getDriver()) { + $this->assertSame('"foo"', $this->db->escapeIdentifier('foo', true), 'Should return "foo".'); + $this->assertSame( + '""foo"."bar""', + $this->db->escapeIdentifier('"foo"."bar"', true), + 'Should return ""foo"."bar"".' + ); + } + } + + public function test_getBackend() + { + $this->assertInstanceOf(PDO::class, $this->db->getBackend(), 'Should return the PDO instance.'); + } + + public function test_select() + { + $this->db->insert('test', [ + 'name' => 'test', + 'watched' => 1, + 'added_at' => 1, + 'updated_at' => 2, + 'json_data' => json_encode([ + 'my_id' => 1, + 'my_name' => 'test', + 'my_data' => [ + 'my_id' => 1, + 'my_name' => 'test', + ], + ]), + ]); + + $this->db->insert('test', [ + 'name' => 'test2', + 'watched' => 0, + 'added_at' => 3, + 'updated_at' => 4, + 'json_data' => json_encode([ + 'my_id' => 2, + 'my_name' => 'test2', + 'my_data' => [ + 'my_id' => 2, + 'my_name' => 'test2', + ], + ]), + ]); + + $data1 = $this->db->select('test', [], ['id' => 1])->fetch(); + $data2 = $this->db->select('test', [], ['id' => 2])->fetch(); + + $this->checkException( + closure: fn() => $this->db->select('test', ['id' => 1], ['id' => 1]), + reason: 'Should throw TypeError exception if cols value is not a string.', + exception: TypeError::class, + exceptionMessage: 'must be of type string', + ); + + $this->checkException( + closure: fn() => $this->db->select('test', ['*'], ['id'], options: ['count' => true]), + reason: 'Should throw exception if conditions parameter is not an key/value pairs.', + exception: TypeError::class, + exceptionMessage: 'must be of type string', + ); + + $this->assertSame( + $data1, + $this->db->select('test', [], ['id' => 1])->fetch(), + 'Should return the record with id 1.' + ); + $this->assertSame( + $data2, + $this->db->select('test', [], ['id' => 2])->fetch(), + 'Should return the record with id 2.' + ); + + $this->assertSame( + $data2, + $this->db->select('test', [], ['id' => 2], options: [ + 'orderby' => ['id' => 'DESC'], + 'limit' => 1, + 'start' => 0, + 'groupby' => ['id'], + ])->fetch(), + 'Should return the record with id 2.' + ); + } + + public function test_lock_retry() + { + /** @noinspection PhpUnhandledExceptionInspection */ + $random = random_int(1, 100); + + $this->db->transactional(function (DBLayer $db, array $options = []) use ($random) { + // -- trigger database lock exception + if ((int)ag($options, 'attempts', 0) < 1) { + throw new PDOException('database is locked'); + } + + $db->insert('sqlite_sequence', ['name' => 'test-' . $random, 'seq' => 1]); + }, options: [ + 'max_sleep' => 0, + ]); + + $this->assertSame(1, $this->db->getCount('sqlite_sequence', ['name' => 'test-' . $random])); + + $this->checkException( + closure: function () use ($random) { + $this->db->transactional(fn() => throw new PDOException('database is locked'), options: [ + 'max_sleep' => 0, + 'max_attempts' => 1, + ]); + }, + reason: 'Should throw an exception when the maximum number of attempts is reached.', + exception: DBLayerException::class, + exceptionMessage: 'database is locked', + ); + $this->checkException( + closure: function () use ($random) { + $this->db->transactional(fn() => throw new PDOException('database is locked'), options: [ + 'max_sleep' => 0, + 'max_attempts' => 1, + 'on_lock' => fn() => throw new DBLayerException('on_lock called'), + ]); + }, + reason: 'Should throw an exception when the maximum number of attempts is reached.', + exception: DBLayerException::class, + exceptionMessage: 'on_lock called', + ); + } + + public function test_condition_parser() + { + $this->db->insert('test', [ + 'name' => 'test', + 'watched' => 1, + 'added_at' => 1, + 'updated_at' => 2, + 'json_data' => json_encode([ + 'my_id' => 1, + 'my_name' => 'test', + 'my_data' => [ + 'my_id' => 1, + 'my_name' => 'test', + ], + ]), + ]); + + $this->db->insert('test', [ + 'name' => 'test2', + 'watched' => 0, + 'added_at' => 3, + 'updated_at' => 4, + 'json_data' => json_encode([ + 'my_id' => 2, + 'my_name' => 'test2', + 'my_data' => [ + 'my_id' => 2, + 'my_name' => 'test2', + ], + ]), + ]); + + + $data1 = $this->db->select('test', [], ['id' => 1])->fetch(); + $data2 = $this->db->select('test', [], ['id' => 2])->fetch(); + + $this->db->insert('fts_table', ['name' => $data1['name'], 'json_data' => $data1['json_data']]); + $this->db->insert('fts_table', ['name' => $data2['name'], 'json_data' => $data2['json_data']]); + + $this->assertSame( + $data1, + $this->db->select('test', [], [ + 'id' => [DBLayer::IS_LOWER_THAN_OR_EQUAL, 1], + ])->fetch(), + 'Should return the record with id 1.' + ); + + $this->assertSame( + $data2, + $this->db->select('test', [], [ + 'id' => [DBLayer::IS_HIGHER_THAN_OR_EQUAL, 2], + ])->fetch(), + 'Should return the record with id 1.' + ); + $this->assertSame( + $data1, + $this->db->select('test', [], [ + 'added_at' => [DBLayer::IS_BETWEEN, [1, 2]], + ])->fetch(), + 'Should return the record with id 1.' + ); + + $this->assertSame( + $data2, + $this->db->select('test', [], [ + 'added_at' => [DBLayer::IS_NOT_BETWEEN, [1, 2]], + ])->fetch(), + 'Should return the record with id 2.' + ); + + $this->assertSame( + $data1, + $this->db->select('test', [], [ + 'nullable' => [DBLayer::IS_NULL], + ])->fetch(), + 'Should return the record with id 1.' + ); + + $this->assertSame( + $data2, + $this->db->select('test', [], [ + 'name' => [DBLayer::IS_LIKE, 'test2'], + ])->fetch(), + 'Should return the record with id 2.' + ); + + $this->assertSame( + $data1, + $this->db->select('test', [], [ + 'name' => [DBLayer::IS_NOT_LIKE, 'test2'], + ])->fetch(), + 'Should return the record with id 1.' + ); + $this->assertSame( + $data1, + $this->db->select('test', [], [ + 'id' => [DBLayer::IS_IN, [0, 1]], + ])->fetch(), + 'Should return the record with id 1.' + ); + + $this->assertSame( + $data2, + $this->db->select('test', [], [ + 'id' => [DBLayer::IS_NOT_IN, [0, 1]], + ])->fetch(), + 'Should return the record with id 2.' + ); + + try { + $this->assertSame( + $data2, + $this->db->select('test', [], ['json_data' => [DBLayer::IS_JSON_CONTAINS, '$.my_id', 2],])->fetch(), + 'Should return the record with id 1.' + ); + } catch (DBLayerException $e) { + if (str_contains($e->getMessage(), 'no such function') && 'sqlite' === $this->db->getDriver()) { + // -- pass as sqlite does not support json_contains + } else { + throw $e; + } + } + + $this->checkException( + closure: fn() => $this->db->select('test', [], ['json_data' => [DBLayer::IS_JSON_CONTAINS, '$.my_id'],] + )->fetch(), + reason: 'Should throw an exception when json_contains receives less then expected parameters.', + exception: RuntimeException::class, + exceptionMessage: 'IS_JSON_CONTAINS: expects 2', + ); + + $this->assertSame( + $data2, + $this->db->select('test', [], ['json_data' => [DBLayer::IS_JSON_EXTRACT, '$.my_id', '>', 1]])->fetch(), + 'Should return the record with id 2.' + ); + + $this->checkException( + closure: fn() => $this->db->select('test', [], ['json_data' => [DBLayer::IS_JSON_EXTRACT, '$.my_id', '>']]), + reason: 'Should throw an exception when json_extract receives less then expected parameters.', + exception: RuntimeException::class, + exceptionMessage: 'IS_JSON_EXTRACT: expects 3', + ); + + $this->checkException( + closure: fn() => $this->db->select('test', [], ['json_data' => ['NOT_SET', '$.my_id', '>']]), + reason: 'Should throw exception on unknown operator.', + exception: RuntimeException::class, + exceptionMessage: 'expr not implemented', + ); + + $this->assertSame( + 'test', + $this->db->select('fts_table', [], [ + 'name' => [DBLayer::IS_MATCH_AGAINST, ['name'], 'test'], + ])->fetch()['name'], + 'Should return the record with id 2.' + ); + + $this->checkException( + closure: fn() => $this->db->select('fts_table', [], [ + 'name' => [DBLayer::IS_MATCH_AGAINST, ['name']], + ])->fetch(), + reason: 'Should throw an exception when match against receives less then expected parameters.', + exception: RuntimeException::class, + exceptionMessage: 'IS_MATCH_AGAINST: expects 2', + ); + + $this->checkException( + closure: fn() => $this->db->select('fts_table', [], [ + 'name' => [DBLayer::IS_MATCH_AGAINST, 'name', 'test'], + ])->fetch(), + reason: 'Should throw an exception when match against receives less then expected parameters.', + exception: RuntimeException::class, + exceptionMessage: 'IS_MATCH_AGAINST: expects parameter 1 to be array', + ); + + $this->checkException( + closure: fn() => $this->db->select('fts_table', [], [ + 'name' => [DBLayer::IS_MATCH_AGAINST, ['name'], ['test']], + ])->fetch(), + reason: 'Should throw an exception when match against receives less then expected parameters.', + exception: RuntimeException::class, + exceptionMessage: 'IS_MATCH_AGAINST: expects parameter 2 to be string', + ); + } + +} diff --git a/tests/Database/PDOAdapterTest.php b/tests/Database/PDOAdapterTest.php index 3c09b9c0..ae1158d7 100644 --- a/tests/Database/PDOAdapterTest.php +++ b/tests/Database/PDOAdapterTest.php @@ -1,24 +1,25 @@ db = new PDOAdapter($logger, new DBLayer(new PDO('sqlite::memory:'))); + $this->db->setOptions([ + Options::DEBUG_TRACE => true, + 'class' => new StateEntity([]), + ]); + $this->db->setLogger($logger); $this->db->migrations('up'); } public function test_insert_throw_exception_if_has_id(): void { - $this->expectException(DBException::class); - $this->expectExceptionCode(21); - $item = new StateEntity($this->testEpisode); - $this->db->insert($item); - $this->db->insert($item); + $this->checkException( + closure: function () { + $item = new StateEntity($this->testEpisode); + $this->db->insert($item); + $this->db->insert($item); + }, + reason: 'When inserting item with id, an exception should be thrown.', + exception: DBException::class, + exceptionMessage: 'primary key already defined', + exceptionCode: 21, + ); + } + + public function test_insert_conditions(): void + { + $this->checkException( + closure: function () { + $item = new StateEntity($this->testEpisode); + $item->type = 'invalid'; + $this->db->insert($item); + }, + reason: 'When inserting item with id, an exception should be thrown.', + exception: DBException::class, + exceptionMessage: 'Unexpected content type', + exceptionCode: 22, + ); + $this->checkException( + closure: function () { + $item = new StateEntity($this->testEpisode); + $item->episode = 0; + $this->db->insert($item); + }, + reason: 'When inserting episode item with episode number 0, an exception should be thrown.', + exception: DBException::class, + exceptionMessage: 'Unexpected episode number', + ); } public function test_insert_successful(): void { - $item = $this->db->insert(new StateEntity($this->testEpisode)); + $item = new StateEntity($this->testEpisode); + $item->created_at = 0; + $item->updated_at = 0; + $item->watched = 0; + + $item = $this->db->insert($item); $this->assertSame(1, $item->id, 'When inserting new item, id is set to 1 when db is empty.'); + + $item = new StateEntity($this->testMovie); + $item->created_at = 0; + $item->updated_at = 0; + $item->watched = 0; + $item->setMetadata([ + iState::COLUMN_META_DATA_PLAYED_AT => null, + ]); + + $item = $this->db->insert($item); + $this->assertSame(2, $item->id, 'When inserting new item, id is set to 1 when db is empty.'); } - /** - * @throws RandomException - */ public function test_get_conditions(): void { $test = $this->testEpisode; - foreach (StateInterface::ENTITY_ARRAY_KEYS as $key) { + foreach (iState::ENTITY_ARRAY_KEYS as $key) { if (null === ($test[$key] ?? null)) { continue; } @@ -104,9 +154,13 @@ class PDOAdapterTest extends TestCase public function test_getAll_call_without_initialized_container(): void { - $this->expectException(Error::class); - $this->expectExceptionMessage('Call to a member function'); - $this->db->getAll(); + $this->db->setOptions(['class' => null]); + $this->checkException( + closure: fn() => $this->db->getAll(), + reason: 'When calling getAll without initialized container, an exception should be thrown.', + exception: Error::class, + exceptionMessage: 'Call to a member function', + ); } public function test_getAll_conditions(): void @@ -133,20 +187,34 @@ class PDOAdapterTest extends TestCase ); } - public function test_update_call_without_id_exception(): void + public function test_update_fail_conditions(): void { - $this->expectException(DBException::class); - $this->expectExceptionCode(51); - $item = new StateEntity($this->testEpisode); + $this->checkException( + closure: fn() => $this->db->update(new StateEntity($this->testEpisode)), + reason: 'When updating item without id, an exception should be thrown.', + exception: DBException::class, + exceptionMessage: 'without primary key', + exceptionCode: 51, + ); - $this->db->update($item); + $this->checkException( + closure: function () { + $item = new StateEntity($this->testEpisode); + $this->db->insert($item); + $item->episode = 0; + $this->db->update($item); + }, + reason: 'When inserting episode item with episode number 0, an exception should be thrown.', + exception: DBException::class, + exceptionMessage: 'Unexpected episode number', + ); } public function test_update_conditions(): void { $test = $this->testEpisode; - foreach (StateInterface::ENTITY_ARRAY_KEYS as $key) { + foreach (iState::ENTITY_ARRAY_KEYS as $key) { if (null === ($test[$key] ?? null)) { continue; } @@ -161,12 +229,23 @@ class PDOAdapterTest extends TestCase $this->assertSame($item, $updatedItem, 'When updating item, same object is returned.'); $r = $this->db->get($item)->getAll(); - $updatedItem->updated_at = $r[StateInterface::COLUMN_UPDATED_AT]; + $updatedItem->updated_at = $r[iState::COLUMN_UPDATED_AT]; $this->assertSame( $updatedItem->getAll(), $r, 'When updating item, getAll should return same values as the recorded item.' ); + + $updatedItem->watched = 0; + $item->setMetadata([ + iState::COLUMN_META_DATA_PLAYED_AT => null, + ]); + $item = $this->db->update($item); + + $this->assertNull( + ag($item->getMetadata($item->via), iState::COLUMN_META_DATA_PLAYED_AT), + 'When watched flag is set to 0, played_at metadata should be null.' + ); } public function test_remove_conditions(): void @@ -185,7 +264,7 @@ class PDOAdapterTest extends TestCase 'When db is not empty, remove returns true if record removed.' ); $this->assertInstanceOf( - StateInterface::class, + iState::class, $this->db->get($item2), 'When Record exists an instance of StateInterface is returned.' ); @@ -205,6 +284,13 @@ class PDOAdapterTest extends TestCase $this->db->remove($item3), 'If record does not have id and/or pointers, return false.' ); + + $item1 = new StateEntity($this->testEpisode); + $this->db->insert($item1); + $this->assertTrue( + $this->db->remove(new StateEntity($this->testEpisode)), + 'When removing item with id, return true.' + ); } public function test_commit_conditions(): void @@ -218,8 +304,8 @@ class PDOAdapterTest extends TestCase 'Array with count of each operation status.' ); - $item1->guids['guid_anidb'] = StateInterface::TYPE_EPISODE . '/1'; - $item2->guids['guid_anidb'] = StateInterface::TYPE_MOVIE . '/1'; + $item1->guids['guid_anidb'] = iState::TYPE_EPISODE . '/1'; + $item2->guids['guid_anidb'] = iState::TYPE_MOVIE . '/1'; $this->assertSame( ['added' => 0, 'updated' => 2, 'failed' => 0], @@ -230,8 +316,156 @@ class PDOAdapterTest extends TestCase public function test_migrations_call_with_wrong_direction_exception(): void { - $this->expectException(DBException::class); - $this->expectExceptionCode(91); - $this->db->migrations('not_dd'); + $this->checkException( + closure: fn() => $this->db->migrations('not_dd'), + reason: 'When calling migrations with wrong direction, an exception should be thrown.', + exception: DBException::class, + exceptionMessage: 'Unknown migration direction', + exceptionCode: 91, + ); + } + + public function test_commit_transaction_on__destruct(): void + { + $started = $this->db->getDBLayer()->start(); + $this->assertTrue($started, 'Transaction should be started.'); + + $this->db->getDBLayer()->transactional(function () { + $this->db->insert(new StateEntity($this->testEpisode)); + $this->db->insert(new StateEntity($this->testMovie)); + }, auto: false); + + $this->assertTrue($this->db->getDBLayer()->inTransaction(), 'Transaction should be still open.'); + $this->db->__destruct(); + $this->assertFalse($this->db->getDBLayer()->inTransaction(), 'Transaction should be closed.'); + + $this->assertCount( + 2, + $this->db->getAll(), + 'When transaction is committed, records should be found in db.' + ); + } + + public function test_find(): void + { + $item1 = new StateEntity($this->testEpisode); + $item2 = new StateEntity($this->testMovie); + $this->db->insert($item1); + $this->db->insert($item2); + + $items = $this->db->find($item1, $item2, new StateEntity([])); + + $this->assertCount(2, $items, 'Only items that are found should be returned.'); + $this->assertSame($item1->id, array_values($items)[0]->id, 'When items are found, they should be returned.'); + $this->assertSame($item2->id, array_values($items)[1]->id, 'When items are found, they should be returned.'); + } + + public function test_findByBackendId(): void + { + Container::init(); + Container::add(iState::class, new StateEntity([])); + + $this->db->setOptions(['class' => null]); + $item1 = new StateEntity($this->testEpisode); + $item2 = new StateEntity($this->testMovie); + $this->db->insert($item1); + $this->db->insert($item2); + + $item1_db = $this->db->findByBackendId( + $item1->via, + ag($item1->getMetadata($item1->via), iState::COLUMN_ID), + $item1->type, + ); + + $this->assertCount(0, $item1_db->apply($item1)->diff(), 'When item is found, it should be returned.'); + $this->assertNull( + $this->db->findByBackendId('not_set', 0, 'movie'), + 'When item is not found, null should be returned.' + ); + + $this->db->setOptions(['class' => new StateEntity([])]); + + $item2_db = $this->db->findByBackendId( + $item2->via, + ag($item2->getMetadata($item2->via), iState::COLUMN_ID), + $item2->type, + ); + $this->assertCount(0, $item2_db->apply($item2)->diff(), 'When item is found, it should be returned.'); + } + + public function test_ensureIndex() + { + $this->assertTrue($this->db->ensureIndex(), 'When ensureIndex is called, it should return true.'); + } + + public function test_migrateData() + { + Config::init(require __DIR__ . '/../../config/config.php'); + $this->assertFalse( + $this->db->migrateData(Config::get('database.version')), + 'At this point we are starting with new database, so migration should be false.' + ); + } + + public function test_maintenance() + { + Config::init(require __DIR__ . '/../../config/config.php'); + $this->assertTrue( + 0 === $this->db->maintenance(), + 'At this point we are starting with new database, so maintenance should be false.' + ); + } + + public function test_reset() + { + $this->assertTrue($this->db->reset(), 'When reset is called, it should return true. and reset the db.'); + } + + public function test_transaction() + { + $this->db->getDBLayer()->start(); + $this->checkException( + closure: function () { + return $this->db->transactional(fn() => throw new \PDOException('test', 11)); + }, + reason: 'If we started transaction from outside the db, it shouldn\'t swallow the exception.', + exception: \PDOException::class, + exceptionMessage: 'test', + exceptionCode: 11, + ); + $this->db->getDBLayer()->rollback(); + + + $this->db->getDBLayer()->start(); + $this->db->transactional(fn($db) => $db->insert(new StateEntity($this->testEpisode))); + $this->db->getDBLayer()->commit(); + + $this->checkException( + closure: function () { + return $this->db->transactional(fn() => throw new \PDOException('test', 11)); + }, + reason: 'The exception should be thrown after rollback.', + exception: \PDOException::class, + exceptionMessage: 'test', + exceptionCode: 11, + ); + } + + public function test_isMigrated() + { + Config::init(require __DIR__ . '/../../config/config.php'); + $db = new PDOAdapter(new Logger('logger'), new DBLayer(new PDO('sqlite::memory:'))); + $this->assertFalse( + $db->isMigrated(), + 'At this point we are starting with new database, so migration should be false.' + ); + $this->assertTrue( + 0 === $db->migrations('up'), + 'When migrations are run, it should return true.' + ); + $this->assertTrue( + $db->isMigrated(), + 'When migrations are run, it should return true.' + ); } } diff --git a/tests/Fixtures/static_data/test.css b/tests/Fixtures/static_data/test.css new file mode 100644 index 00000000..b0bf9bdf --- /dev/null +++ b/tests/Fixtures/static_data/test.css @@ -0,0 +1,6 @@ +html { + background-color: #f0f0f0; + font-family: Arial, sans-serif; + font-size: 16px; + color: #333; +} diff --git a/tests/Fixtures/static_data/test.html b/tests/Fixtures/static_data/test.html new file mode 100644 index 00000000..0d2fedff --- /dev/null +++ b/tests/Fixtures/static_data/test.html @@ -0,0 +1,12 @@ + + + + + + + Document + + + + diff --git a/tests/Fixtures/static_data/test.js b/tests/Fixtures/static_data/test.js new file mode 100644 index 00000000..3caf394d --- /dev/null +++ b/tests/Fixtures/static_data/test.js @@ -0,0 +1 @@ +const testFunc = () => 'test' diff --git a/tests/Fixtures/static_data/test.json b/tests/Fixtures/static_data/test.json new file mode 100644 index 00000000..71374789 --- /dev/null +++ b/tests/Fixtures/static_data/test.json @@ -0,0 +1,3 @@ +{ + "test": "test" +} diff --git a/tests/Fixtures/static_data/test.md b/tests/Fixtures/static_data/test.md new file mode 100644 index 00000000..c0b806e1 --- /dev/null +++ b/tests/Fixtures/static_data/test.md @@ -0,0 +1 @@ +# Test markdown diff --git a/tests/Fixtures/static_data/test.woff2 b/tests/Fixtures/static_data/test.woff2 new file mode 100644 index 00000000..9daeafb9 --- /dev/null +++ b/tests/Fixtures/static_data/test.woff2 @@ -0,0 +1 @@ +test diff --git a/tests/Fixtures/static_data/test/index.html b/tests/Fixtures/static_data/test/index.html new file mode 100644 index 00000000..77523a94 --- /dev/null +++ b/tests/Fixtures/static_data/test/index.html @@ -0,0 +1 @@ +test_index.html diff --git a/tests/Fixtures/static_data/test2/test.html b/tests/Fixtures/static_data/test2/test.html new file mode 100644 index 00000000..9daeafb9 --- /dev/null +++ b/tests/Fixtures/static_data/test2/test.html @@ -0,0 +1 @@ +test diff --git a/tests/Libs/DataUtilTest.php b/tests/Libs/DataUtilTest.php new file mode 100644 index 00000000..7c9cc9cb --- /dev/null +++ b/tests/Libs/DataUtilTest.php @@ -0,0 +1,205 @@ + 1, 'limit' => 10]; + private array $post = [ + 'foo' => 'bar', + 'baz' => 'kaz', + 'sub' => ['key' => 'val'], + 'bool' => true, + 'int' => 1, + 'float' => 1.1 + ]; + + private iRequest|null $request = null; + + protected function setUp(): void + { + parent::setUp(); + $factory = new Psr17Factory(); + $creator = new ServerRequestCreator($factory, $factory, $factory, $factory); + + $this->request = $creator->fromArrays( + server: [ + 'REQUEST_METHOD' => 'GET', + 'SCRIPT_FILENAME' => realpath(__DIR__ . '/../../public/index.php'), + 'REMOTE_ADDR' => '127.0.0.1', + 'REQUEST_URI' => '/', + 'SERVER_NAME' => 'localhost', + 'SERVER_PORT' => 80, + 'HTTP_USER_AGENT' => 'WatchState/0.0', + ], + headers: [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer api_test_token', + ], + cookie: ['test' => 'cookie'], + get: $this->query, + post: $this->post, + files: [ + 'file' => [ + 'name' => 'test_servers.yaml', + 'type' => 'text/plain', + 'size' => filesize(__DIR__ . '/../Fixtures/test_servers.yaml'), + 'error' => UPLOAD_ERR_OK, + 'tmp_name' => __DIR__ . '/../Fixtures/test_servers.yaml', + ], + ] + ); + } + + public function test_DataUtil_fromArray(): void + { + $this->assertSame($this->query, DataUtil::fromArray($this->query)->getAll(), 'fromArray() returns all data'); + $this->assertSame($this->post, DataUtil::fromArray($this->post)->getAll(), 'fromArray() returns all data'); + } + + public function test_DataUtil_fromRequest(): void + { + $this->assertSame( + $this->post, + DataUtil::fromRequest($this->request)->getAll(), + 'fromRequest() returns all data post data, Default is without query params.' + ); + + $this->assertSame( + $this->post, + DataUtil::fromRequest($this->request, includeQueryParams: false)->getAll(), + 'fromRequest() return only post data, without query params when includeQueryParams is explicitly set to false.' + ); + + $this->assertSame( + array_replace_recursive($this->query, $this->post), + DataUtil::fromRequest($this->request, includeQueryParams: true)->getAll(), + 'fromRequest() returns all data including query params when includeQueryParams is explicitly set to true.' + ); + } + + public function test_DataUtil_get(): void + { + $obj = DataUtil::fromRequest($this->request, includeQueryParams: true); + + $this->assertSame($this->query['page'], $obj->get('page'), 'get() returns the value of the key if it exists.'); + $this->assertSame( + $this->query['limit'], + $obj->get('limit'), + 'get() returns the value of the key if it exists.' + ); + $this->assertNull($obj->get('not_set'), 'get() returns null if the key does not exist.'); + $this->assertSame( + 'default', + $obj->get('not_set', 'default'), + 'get() returns the default value if the key does not exist.' + ); + $this->assertIsArray($obj->get('sub'), 'get() returns an array if the key is an array.'); + $this->assertIsBool($obj->get('bool'), 'get() returns a boolean if the key is a boolean.'); + $this->assertIsInt($obj->get('int'), 'get() returns an integer if the key is an integer.'); + $this->assertIsFloat($obj->get('float'), 'get() returns a float if the key is a float.'); + $this->assertIsString($obj->get('foo'), 'get() returns a string if the key is a string.'); + $this->assertSame( + ag($this->post, 'sub.key'), + $obj->get('sub.key'), + 'get() returns the value of the key if it exists.' + ); + } + + public function test_dataUtil_has() + { + $obj = DataUtil::fromRequest($this->request, includeQueryParams: true); + $this->assertTrue($obj->has('page'), 'has() returns true if the key exists.'); + $this->assertTrue($obj->has('limit'), 'has() returns true if the key exists.'); + $this->assertFalse($obj->has('not_set'), 'has() returns false if the key does not exist.'); + $this->assertTrue($obj->has('sub'), 'has() returns true if the key is an array.'); + $this->assertTrue($obj->has('sub.key'), 'has() returns true if the sub.key exists.'); + } + + public function test_dataUtil_map() + { + $obj = DataUtil::fromRequest($this->request, includeQueryParams: true); + $callback = fn($value) => is_string($value) ? strtoupper($value) : $value; + $data = array_replace_recursive($this->query, $this->post); + $this->assertSame( + array_map($callback, $data), + $obj->map($callback)->getAll(), + 'map() returns the array with the callback applied to each value.' + ); + } + + public function test_dataUtil_filter() + { + $data = array_replace_recursive($this->query, $this->post); + $obj = DataUtil::fromRequest($this->request, includeQueryParams: true); + $callback = fn($value, $key) => is_string($value) && $key === 'foo'; + $this->assertSame( + array_filter($data, $callback, ARRAY_FILTER_USE_BOTH), + $obj->filter($callback)->getAll(), + 'filter() returns the array with the callback applied to each value.' + ); + } + + public function test_dataUtil_with() + { + $obj = DataUtil::fromArray($this->query); + $expected = $this->query; + $expected['new'] = 'value'; + + $this->assertSame( + $expected, + $obj->with('new', 'value')->getAll(), + 'with() returns a new DataUtil object with the key and value set.' + ); + } + + public function test_dataUtil_without() + { + $obj = DataUtil::fromArray($this->query); + + $this->assertSame( + ['page' => $this->query['page']], + $obj->without('limit')->getAll(), + 'without() returns a new DataUtil object without the key.' + ); + + $this->assertSame( + $this->query, + $obj->without('not_set')->getAll(), + 'without() returns a new DataUtil object even if the key does not exist with same data.' + ); + + $this->assertNotSame( + spl_object_hash($obj), + spl_object_hash($obj->without('not_set')), + 'without() Any mutation should return a new object. and the spl_object_hash() should not be the same.' + ); + + $this->assertNotSame( + spl_object_id($obj), + spl_object_id($obj->without('not_set')), + 'without() Any mutation should return a new object. and the spl_object_id() should not be the same.' + ); + } + + public function test_dataUtil_jsonSerialize() + { + $obj = DataUtil::fromArray($this->query); + $this->assertSame(json_encode($this->query), json_encode($obj), 'jsonSerialize() returns the data array.'); + } + + public function test_dataUtil_toString() + { + $obj = DataUtil::fromArray($this->query); + $this->assertSame(json_encode($this->query), (string)$obj, 'jsonSerialize() returns the data array.'); + } + +} diff --git a/tests/Libs/ServeStaticTest.php b/tests/Libs/ServeStaticTest.php new file mode 100644 index 00000000..0528e7df --- /dev/null +++ b/tests/Libs/ServeStaticTest.php @@ -0,0 +1,155 @@ +server = new ServeStatic(realpath($this->dataPath)); + } + + private function createRequest(string $method, string $uri, array $headers = []): ServerRequest + { + return new ServerRequest($method, $uri, $headers); + } + + public function test_error_responses() + { + $this->checkException( + closure: fn() => $this->server->serve($this->createRequest('GET', '/nonexistent')), + reason: 'If file does not exist, A NotFoundException should be thrown.', + exception: \League\Route\Http\Exception\NotFoundException::class, + exceptionMessage: 'not found', + exceptionCode: Status::NOT_FOUND->value, + ); + + $this->checkException( + closure: function () { + Config::save('webui.path', '/nonexistent'); + return (new ServeStatic())->serve($this->createRequest('GET', '/nonexistent')); + }, + reason: 'If file does not exist, A NotFoundException should be thrown.', + exception: \League\Route\Http\Exception\BadRequestException::class, + exceptionMessage: 'The static path', + exceptionCode: Status::SERVICE_UNAVAILABLE->value, + ); + + $this->checkException( + closure: fn() => $this->server->serve($this->createRequest('PUT', '/nonexistent.md')), + reason: 'Non-idempotent methods should not be allowed on static files.', + exception: \League\Route\Http\Exception\BadRequestException::class, + exceptionMessage: 'is not allowed', + ); + + // -- Check for LFI vulnerability. + $this->checkException( + closure: fn() => $this->server->serve($this->createRequest('GET', '/../../../composer.json')), + reason: 'Should not allow serving files outside the static directory.', + exception: \League\Route\Http\Exception\BadRequestException::class, + exceptionMessage: 'is invalid.', + exceptionCode: Status::BAD_REQUEST->value, + ); + + // -- Check for invalid root static path. + $this->checkException( + closure: fn() => (new ServeStatic('/nonexistent'))->serve($this->createRequest('GET', '/test.html')), + reason: 'Should throw exception if the static path does not exist.', + exception: \League\Route\Http\Exception\BadRequestException::class, + exceptionMessage: 'The static path', + exceptionCode: Status::SERVICE_UNAVAILABLE->value, + ); + + $this->checkException( + closure: fn() => $this->server->serve($this->createRequest('GET', '/test2/foo/bar')), + reason: 'If file does not exist, A NotFoundException should be thrown.', + exception: \League\Route\Http\Exception\NotFoundException::class, + exceptionMessage: 'not found', + exceptionCode: Status::NOT_FOUND->value, + ); + + $this->checkException( + closure: fn() => $this->server->serve($this->createRequest('GET', '/')), + reason: 'If file does not exist, A NotFoundException should be thrown.', + exception: \League\Route\Http\Exception\NotFoundException::class, + exceptionMessage: 'not found', + exceptionCode: Status::NOT_FOUND->value, + ); + } + + public function test_responses() + { + $response = $this->server->serve($this->createRequest('GET', '/test.html')); + $this->assertEquals(Status::OK->value, $response->getStatusCode()); + $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type')); + $this->assertEquals(file_get_contents($this->dataPath . '/test.html'), (string)$response->getBody()); + $this->assertSame(filesize($this->dataPath . '/test.html'), $response->getBody()->getSize()); + + // -- test screenshots serving, as screenshots path is not in public directory and not subject + // -- to same path restrictions as other files. + $response = $this->server->serve($this->createRequest('GET', '/screenshots/add_backend.png')); + $this->assertEquals(Status::OK->value, $response->getStatusCode()); + $this->assertEquals('image/png', $response->getHeaderLine('Content-Type')); + $this->assertEquals( + file_get_contents(__DIR__ . '/../../screenshots/add_backend.png'), + (string)$response->getBody() + ); + + // -- There are similar rules for .md files test them. + $response = $this->server->serve($this->createRequest('GET', '/README.md')); + $this->assertEquals(Status::OK->value, $response->getStatusCode()); + $this->assertEquals('text/markdown; charset=utf-8', $response->getHeaderLine('Content-Type')); + $this->assertEquals(file_get_contents(__DIR__ . '/../../README.md'), (string)$response->getBody()); + + // -- Check directory serving. + $response = $this->server->serve($this->createRequest('GET', '/test')); + $this->assertEquals(Status::OK->value, $response->getStatusCode()); + $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type')); + $this->assertEquals( + file_get_contents(__DIR__ . '/../Fixtures/static_data/test/index.html'), + (string)$response->getBody() + ); + + $response = $this->server->serve($this->createRequest('GET', '/test.html', [ + 'if-modified-since' => gmdate('D, d M Y H:i:s T', filemtime($this->dataPath . '/test.html')), + ])); + + $this->assertEquals(Status::NOT_MODIFIED->value, $response->getStatusCode()); + + // -- check for deep index lookup. + $response = $this->server->serve($this->createRequest('GET', '/test/view/action/1')); + $this->assertEquals(Status::OK->value, $response->getStatusCode()); + $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type')); + $this->assertEquals( + file_get_contents(__DIR__ . '/../Fixtures/static_data/test/index.html'), + (string)$response->getBody() + ); + + $response = $this->server->serve($this->createRequest('GET', '/test/view/1')); + $this->assertEquals(Status::OK->value, $response->getStatusCode()); + + + $response = $this->server->serve($this->createRequest('GET', '/test.html', [ + 'if-modified-since' => '$$ INVALID DATA', + ])); + + $this->assertEquals( + Status::OK->value, + $response->getStatusCode(), + 'If the date is invalid, the file should be served as normal.' + ); + } +} diff --git a/tests/Libs/StateEntityTest.php b/tests/Libs/StateEntityTest.php index 7a21518c..638b9598 100644 --- a/tests/Libs/StateEntityTest.php +++ b/tests/Libs/StateEntityTest.php @@ -17,41 +17,28 @@ class StateEntityTest extends TestCase private array $testMovie = []; private array $testEpisode = []; - private TestHandler|null $lHandler = null; - private Logger|null $logger = null; - protected function setUp(): void { $this->testMovie = require __DIR__ . '/../Fixtures/MovieEntity.php'; $this->testEpisode = require __DIR__ . '/../Fixtures/EpisodeEntity.php'; - $this->lHandler = new TestHandler(); - $this->logger = new Logger('logger', processors: [new LogMessageProcessor()]); - $this->logger->pushHandler($this->lHandler); + $logger = new Logger('logger', processors: [new LogMessageProcessor()]); + $logger->pushHandler(new TestHandler()); } public function test_init_bad_type(): void { $this->testMovie[iState::COLUMN_TYPE] = 'oi'; - try { - new StateEntity($this->testMovie); - } catch (RuntimeException $e) { - $this->assertInstanceOf( - RuntimeException::class, - $e, - 'When new instance of StateEntity is called with invalid type, exception is thrown' - ); - } - - try { - StateEntity::fromArray($this->testMovie); - } catch (RuntimeException $e) { - $this->assertInstanceOf( - RuntimeException::class, - $e, - 'When ::fromArray is called with invalid type, exception is thrown' - ); - } + $this->checkException( + closure: fn() => new StateEntity($this->testMovie), + reason: 'When new instance of StateEntity is called with invalid type, exception is thrown', + exception: RuntimeException::class, + ); + $this->checkException( + closure: fn() => StateEntity::fromArray($this->testMovie), + reason: 'When ::fromArray is called with invalid type, exception is thrown', + exception: RuntimeException::class, + ); } public function test_init_bad_data(): void @@ -84,11 +71,16 @@ class StateEntityTest extends TestCase public function test_diff_array_param(): void { $entity = new StateEntity($this->testEpisode); - $entity->setMetadata([iState::COLUMN_META_DATA_PLAYED_AT => 4]); + $entity->setMetadata([ + iState::COLUMN_META_DATA_PLAYED_AT => 4, + 'test' => ['foo' => 'bar'], + ]); $arr = []; $arr = ag_set($arr, 'metadata.home_plex.played_at.old', 2); $arr = ag_set($arr, 'metadata.home_plex.played_at.new', 4); + $arr = ag_set($arr, 'metadata.home_plex.test.old', 'None'); + $arr = ag_set($arr, 'metadata.home_plex.test.new', ['foo' => 'bar']); $this->assertSame( $arr, @@ -538,6 +530,16 @@ class StateEntityTest extends TestCase $entity->diff(), 'When apply() is called with fields that contain changed keys, only those fields are applied to current entity.' ); + + $data1 = $this->testMovie; + $data1[iState::COLUMN_ID] = 1; + $data2 = $this->testMovie; + $data2[iState::COLUMN_ID] = 2; + + $id1 = new StateEntity($data1); + $id2 = new StateEntity($data2); + + $this->assertSame(1, $id1->apply($id2)->id, 'When apply() should not alter the object ID.'); } public function test_updateOriginal(): void @@ -588,9 +590,14 @@ class StateEntityTest extends TestCase 'When setIsTainted() is called with true, isTainted() returns true' ); - $this->expectException(\TypeError::class); - /** @noinspection PhpStrictTypeCheckingInspection */ - $entity->setIsTainted('foo'); + $this->checkException( + closure: function () use ($entity) { + /** @noinspection PhpStrictTypeCheckingInspection */ + return $entity->setIsTainted('foo'); + }, + reason: 'When setIsTainted() is called with invalid type, exception is thrown', + exception: \TypeError::class, + ); } public function test_isTainted(): void @@ -641,8 +648,12 @@ class StateEntityTest extends TestCase unset($this->testMovie[iState::COLUMN_VIA]); $entity = new StateEntity($this->testMovie); - $this->expectException(RuntimeException::class); - $entity->setMetadata([]); + + $this->checkException( + closure: fn() => $entity->setMetadata([]), + reason: 'When setMetadata() called with empty array, an exception is thrown', + exception: RuntimeException::class, + ); } public function test_getExtra(): void @@ -686,8 +697,11 @@ class StateEntityTest extends TestCase unset($this->testMovie[iState::COLUMN_VIA]); $entity = new StateEntity($this->testMovie); - $this->expectException(RuntimeException::class); - $entity->setExtra([]); + $this->checkException( + closure: fn() => $entity->setExtra([]), + reason: 'When setExtra() called with empty array, an exception is thrown', + exception: RuntimeException::class, + ); } public function test_shouldMarkAsUnplayed(): void @@ -756,6 +770,16 @@ class StateEntityTest extends TestCase 'When metadata played date is missing, shouldMarkAsUnplayed() returns false' ); + // -- Condition 3: no metadata for via. + $data1 = $this->testMovie; + $data = $this->testMovie; + $data[iState::COLUMN_VIA] = 'not_set'; + $data[iState::COLUMN_WATCHED] = 0; + $this->assertFalse( + StateEntity::fromArray($data1)->shouldMarkAsUnplayed(StateEntity::fromArray($data)), + 'When no metadata set for a backend, shouldMarkAsUnplayed() returns false' + ); + // -- Condition 5: metadata played is false. $data = $this->testMovie; $data[iState::COLUMN_META_DATA][$this->testMovie[iState::COLUMN_VIA]][iState::COLUMN_WATCHED] = 0; @@ -845,7 +869,7 @@ class StateEntityTest extends TestCase public function test_getPlayProgress(): void { - $testData = ag_set($this->testMovie, 'watched', 0); + $testData = ag_set($this->testMovie, iState::COLUMN_WATCHED, 0); $testData = ag_set($testData, 'metadata.home_plex.watched', 0); $entity = new StateEntity($testData); $this->assertSame( @@ -854,7 +878,7 @@ class StateEntityTest extends TestCase 'When hasPlayProgress() when valid play progress is set, returns true' ); - $testData = ag_set($this->testMovie, 'watched', 0); + $testData = ag_set($this->testMovie, iState::COLUMN_WATCHED, 0); $testData = ag_set($testData, 'metadata.home_plex.watched', 0); $testData = ag_set($testData, 'metadata.test_plex', ag($testData, 'metadata.home_plex', [])); $testData = ag_set($testData, 'metadata.test.progress', 999); @@ -865,6 +889,17 @@ class StateEntityTest extends TestCase $entity->getPlayProgress(), 'When hasPlayProgress() when valid play progress is set, returns true' ); + + $testData[iState::COLUMN_WATCHED] = 1; + $entity = new StateEntity($testData); + $this->assertSame(0, $entity->getPlayProgress(), 'When entity is watched, getPlayProgress() returns 0'); + + $testData = ag_set($this->testMovie, iState::COLUMN_WATCHED, 0); + $testData = ag_set($testData, 'metadata.home_plex.watched', 1); + $testData = ag_set($testData, 'metadata.test_plex', ag($testData, 'metadata.home_plex', [])); + $testData = ag_set($testData, 'metadata.test.progress', 999); + $entity = new StateEntity($testData); + $this->assertSame(0, $entity->getPlayProgress(), 'When entity is watched, getPlayProgress() returns 0'); } public function test_context(): void @@ -960,4 +995,63 @@ class StateEntityTest extends TestCase 'Quorum will not be met if one of the values is null.' ); } + + public function test_updated_added_at_columns() + { + $data = $this->testMovie; + + $data[iState::COLUMN_CREATED_AT] = 0; + $data[iState::COLUMN_UPDATED_AT] = 0; + + $entity = new StateEntity($data); + + $this->assertSame( + $this->testMovie[iState::COLUMN_UPDATED], + $entity->updated_at, + 'When entity is created with updated_at set to 0, updated_at is set to updated date from metadata' + ); + + $this->assertSame( + $this->testMovie[iState::COLUMN_UPDATED], + $entity->created_at, + 'When entity is created with created_at set to 0, created_at is set to updated date from metadata' + ); + } + + public function test_decoding_array_fields() + { + $data = $this->testMovie; + + $data[iState::COLUMN_PARENT] = 'garbage data'; + $data[iState::COLUMN_GUIDS] = 'garbage data'; + $data[iState::COLUMN_META_DATA] = 'garbage data'; + $data[iState::COLUMN_EXTRA] = 'garbage data'; + + $entity = new StateEntity($data); + + $this->assertSame([], + $entity->getMetadata(), + 'When array keys are json decode fails, getMetadata() should returns empty array' + ); + + $this->assertSame([], + $entity->getGuids(), + 'When array keys are json decode fails, getGuids() should returns empty array' + ); + + $this->assertSame([], + $entity->getParentGuids(), + 'When array keys are json decode fails, getParentGuids() should returns empty array' + ); + + $this->assertSame([], + $entity->getExtra(), + 'When array keys are json decode fails, getExtra() should returns empty array' + ); + + $this->assertSame([], + $entity->getPointers(), + 'When array keys are json decode fails, getPointers() should returns empty array' + ); + } } diff --git a/tests/Libs/envFileTest.php b/tests/Libs/envFileTest.php new file mode 100644 index 00000000..3fd55912 --- /dev/null +++ b/tests/Libs/envFileTest.php @@ -0,0 +1,223 @@ +data = [ + "WS_TZ" => "Asia/Kuwait", + "WS_CRON_IMPORT" => "1", + "WS_CRON_EXPORT" => "0", + "WS_CRON_IMPORT_AT" => "16 */1 * * *", + "WS_CRON_EXPORT_AT" => "30 */3 * * *", + "WS_CRON_PUSH_AT" => "*/10 * * * *", + ]; + } + + public function test_constructor() + { + $this->checkException( + closure: fn() => new EnvFile('nonexistent.env', create: false), + reason: 'If file does not exist, and autoCreate is set to false, an exception should be thrown.', + exception: RuntimeException::class, + exceptionMessage: "does not exist.", + ); + + $tmpFile = sys_get_temp_dir() . '/watchstate_test.env'; + try { + $envFile = new EnvFile($tmpFile, create: true); + $this->assertFileExists($tmpFile); + $this->assertEmpty($envFile->get(array_keys($this->data)[0])); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + } + + public function test_get() + { + $tmpFile = sys_get_temp_dir() . '/watchstate_test.env'; + try { + $envFile = new EnvFile($tmpFile, create: true); + $this->assertEmpty($envFile->get(array_keys($this->data)[0])); + + foreach ($this->data as $key => $value) { + $envFile->set($key, $value); + $this->assertEquals($value, $envFile->get($key), "The value of key '{$key}' should be '{$value}'."); + } + + $this->assertNull( + $envFile->get('nonexistent_key'), + "The value of a nonexistent key should be NULL by default." + ); + + $this->assertSame( + 'default', + $envFile->get('nonexistent', 'default'), + "The default value should be returned." + ); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + } + + public function test_set() + { + $tmpFile = sys_get_temp_dir() . '/watchstate_test.env'; + try { + $envFile = new EnvFile($tmpFile, create: true); + $this->assertEmpty($envFile->get(array_keys($this->data)[0])); + + foreach ($this->data as $key => $value) { + $envFile->set($key, $value); + } + $envFile->persist(); + + $envFile = new EnvFile($tmpFile); + + foreach ($this->data as $key => $value) { + $this->assertEquals($value, $envFile->get($key), "The value of key '{$key}' should be '{$value}'."); + } + + $this->assertNull( + $envFile->get('nonexistent_key'), + "The value of a nonexistent key should be NULL by default." + ); + + $this->assertSame( + 'default', + $envFile->get('nonexistent', 'default'), + "The default value should be returned." + ); + + $envFile->set('new_key', true); + + $this->assertTrue( + $envFile->get('new_key'), + "Due to unfortunate design, the value of key bool 'new_key' should be true. until we persist it." + ); + + $envFile->persist(); + $envFile = $envFile->newInstance(); + $this->assertTrue($envFile->has('new_key'), "The key 'new_key' should exist."); + $this->assertSame( + '1', + $envFile->get('new_key'), + "The value of key 'new_key' should be '1' as we cast bool to string." + ); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + } + + public function test_has() + { + $tmpFile = sys_get_temp_dir() . '/watchstate_test.env'; + try { + $envFile = new EnvFile($tmpFile, create: true); + $this->assertEmpty($envFile->get(array_keys($this->data)[0])); + + foreach ($this->data as $key => $value) { + $envFile->set($key, $value); + } + $envFile->persist(); + + $envFile = new EnvFile($tmpFile); + + foreach ($this->data as $key => $value) { + $this->assertTrue($envFile->has($key), "The key '{$key}' should exist."); + } + + $this->assertFalse($envFile->has('nonexistent_key'), "The key 'nonexistent_key' should not exist."); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + } + + public function test_persist() + { + $tmpFile = sys_get_temp_dir() . '/watchstate_test.env'; + try { + $envFile = new EnvFile($tmpFile, create: true); + $this->assertEmpty($envFile->get(array_keys($this->data)[0])); + + foreach ($this->data as $key => $value) { + $envFile->set($key, $value); + } + $envFile->persist(); + + $envFile = new EnvFile($tmpFile); + $this->assertSame($this->data, $envFile->getAll(), "The data should be persisted and retrieved correctly."); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + } + + public function test_getAll() + { + $tmpFile = sys_get_temp_dir() . '/watchstate_test.env'; + try { + $envFile = new EnvFile($tmpFile, create: true); + $this->assertEmpty($envFile->get(array_keys($this->data)[0])); + + foreach ($this->data as $key => $value) { + $envFile->set($key, $value); + } + $envFile->persist(); + + $envFile = new EnvFile($tmpFile); + $this->assertSame($this->data, $envFile->getAll(), "The data should be persisted and retrieved correctly."); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + } + + public function test_remove() + { + $tmpFile = sys_get_temp_dir() . '/watchstate_test.env'; + $key = array_keys($this->data)[0]; + + try { + $envFile = new EnvFile($tmpFile, create: true); + $this->assertEmpty($envFile->get(array_keys($this->data)[0])); + + foreach ($this->data as $key => $value) { + $envFile->set($key, $value); + } + + $envFile->persist(); + $envFile->remove($key); + $envFile->persist(); + + $envFile = new EnvFile($tmpFile); + $this->assertNotSame($this->data, $envFile->getAll(), "The key '{$key}' should be been removed."); + $this->assertArrayNotHasKey($key, $envFile->getAll(), "The key '{$key}' should be been removed."); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + } +}