Merge pull request #549 from arabcoders/dev

Updated unittest.
This commit is contained in:
Abdulmohsen
2024-09-11 20:57:01 +03:00
committed by GitHub
28 changed files with 2185 additions and 792 deletions

View File

@@ -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);
}

View File

@@ -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)),

View File

@@ -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;

View File

@@ -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());
}
}

View File

@@ -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<DBLayer> $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<string, mixed>, 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<string, int>, 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;
}
}

View File

@@ -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.
*

View File

@@ -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;
}
}

View File

@@ -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.
*

View File

@@ -1,212 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Libs;
use App\Libs\Attributes\Route\Route;
use FilesystemIterator;
use InvalidArgumentException;
use PhpToken;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionAttribute;
use ReflectionClass;
use RuntimeException;
use SplFileInfo;
use Throwable;
final readonly class Router
{
/**
* @param array $dirs List of directories to scan for php files.
*/
public function __construct(private array $dirs = [])
{
}
public function getDirs(): array
{
return $this->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<SplFileInfo> $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;
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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) {
}

View File

@@ -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');

View File

@@ -0,0 +1,646 @@
<?php
/** @noinspection SqlResolve, SqlWithoutWhere */
declare(strict_types=1);
namespace Tests\Database;
use App\Libs\Config;
use App\Libs\Database\DBLayer;
use App\Libs\Database\PDO\PDOAdapter;
use App\Libs\Exceptions\DBLayerException;
use App\Libs\Exceptions\ErrorException;
use App\Libs\Exceptions\RuntimeException;
use App\Libs\Guid;
use App\Libs\TestCase;
use Monolog\Handler\TestHandler;
use Monolog\Logger;
use PDO;
use PDOException;
use Throwable;
use TypeError;
class DBLayerTest extends TestCase
{
private DBLayer|null $db = null;
protected TestHandler|null $handler = null;
private function createDB(PDO $pdo): void
{
$pdo->exec('DROP TABLE IF EXISTS "test"');
$pdo->exec(
<<<SQL
CREATE TABLE "test" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"name" TEXT NULL,
"watched" INTEGER NULL DEFAULT 0,
"added_at" INTEGER NULL,
"updated_at" INTEGER NULL,
"json_data" JSON NULL,
"nullable" TEXT NULL
)
SQL
);
$pdo->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',
);
}
}

View File

@@ -1,24 +1,25 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
declare(strict_types=1);
namespace Tests\Database;
use App\Libs\Config;
use App\Libs\Container;
use App\Libs\Database\DatabaseInterface as iDB;
use App\Libs\Database\DBLayer;
use App\Libs\Database\PDO\PDOAdapter;
use App\Libs\Entity\StateEntity;
use App\Libs\Entity\StateInterface;
use App\Libs\Entity\StateInterface as iState;
use App\Libs\Exceptions\DBAdapterException as DBException;
use App\Libs\Guid;
use App\Libs\Options;
use App\Libs\TestCase;
use DateTimeImmutable;
use Error;
use Monolog\Handler\TestHandler;
use Monolog\Logger;
use PDO;
use Random\RandomException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\NullOutput;
@@ -48,32 +49,81 @@ class PDOAdapterTest extends TestCase
Guid::setLogger($logger);
$this->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<added, updated, failed> 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.'
);
}
}

View File

@@ -0,0 +1,6 @@
html {
background-color: #f0f0f0;
font-family: Arial, sans-serif;
font-size: 16px;
color: #333;
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
</body>
</html>

View File

@@ -0,0 +1 @@
const testFunc = () => 'test'

View File

@@ -0,0 +1,3 @@
{
"test": "test"
}

View File

@@ -0,0 +1 @@
# Test markdown

View File

@@ -0,0 +1 @@
test

View File

@@ -0,0 +1 @@
test_index.html

View File

@@ -0,0 +1 @@
test

205
tests/Libs/DataUtilTest.php Normal file
View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace Tests\Libs;
use App\Libs\DataUtil;
use App\Libs\TestCase;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
use Psr\Http\Message\ServerRequestInterface as iRequest;
class DataUtilTest extends TestCase
{
private array $query = ['page' => 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.');
}
}

View File

@@ -0,0 +1,155 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
declare(strict_types=1);
namespace Tests\Libs;
use App\Libs\Config;
use App\Libs\Enums\Http\Status;
use App\Libs\ServeStatic;
use App\Libs\TestCase;
use Nyholm\Psr7\ServerRequest;
class ServeStaticTest extends TestCase
{
private ServeStatic|null $server = null;
private string $dataPath = __DIR__ . '/../Fixtures/static_data';
protected function setUp(): void
{
parent::setUp();
$this->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.'
);
}
}

View File

@@ -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'
);
}
}

223
tests/Libs/envFileTest.php Normal file
View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace Tests\Libs;
use App\Libs\EnvFile;
use App\Libs\Exceptions\RuntimeException;
use App\Libs\TestCase;
class envFileTest extends TestCase
{
private array $data = [];
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}
}
}