Merge pull request #547 from arabcoders/dev

Updates testcases, and fixed bug in emby progress ync
This commit is contained in:
Abdulmohsen
2024-09-07 22:18:53 +03:00
committed by GitHub
59 changed files with 2956 additions and 1162 deletions

View File

@@ -24,6 +24,7 @@ use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\UriInterface; use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface as iLogger; use Psr\Log\LoggerInterface as iLogger;
use Psr\SimpleCache\CacheInterface; use Psr\SimpleCache\CacheInterface;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\NullAdapter; use Symfony\Component\Cache\Adapter\NullAdapter;
use Symfony\Component\Cache\Adapter\RedisAdapter; use Symfony\Component\Cache\Adapter\RedisAdapter;
@@ -121,6 +122,10 @@ return (function (): array {
return new Psr16Cache(new NullAdapter()); return new Psr16Cache(new NullAdapter());
} }
if (true === (defined('IN_TEST_MODE') && true === IN_TEST_MODE)) {
return new Psr16Cache(new ArrayAdapter());
}
$ns = getAppVersion(); $ns = getAppVersion();
if (null !== ($prefix = Config::get('cache.prefix')) && true === isValidName($prefix)) { if (null !== ($prefix = Config::get('cache.prefix')) && true === isValidName($prefix)) {
@@ -199,8 +204,15 @@ return (function (): array {
}, },
], ],
DBLayer::class => [
'class' => fn(PDO $pdo): DBLayer => new DBLayer($pdo),
'args' => [
PDO::class,
],
],
iDB::class => [ iDB::class => [
'class' => function (iLogger $logger, PDO $pdo): iDB { 'class' => function (iLogger $logger, DBLayer $pdo): iDB {
$adapter = new PDOAdapter($logger, $pdo); $adapter = new PDOAdapter($logger, $pdo);
if (true !== $adapter->isMigrated()) { if (true !== $adapter->isMigrated()) {
@@ -216,14 +228,7 @@ return (function (): array {
}, },
'args' => [ 'args' => [
iLogger::class, iLogger::class,
PDO::class, DBLayer::class,
],
],
DBLayer::class => [
'class' => fn(PDO $pdo): DBLayer => new DBLayer($pdo),
'args' => [
PDO::class,
], ],
], ],

View File

@@ -6,7 +6,7 @@ namespace App\API\Backend;
use App\Libs\Config; use App\Libs\Config;
use App\Libs\ConfigFile; use App\Libs\ConfigFile;
use App\Libs\Database\DatabaseInterface as iDB; use App\Libs\Database\DBLayer;
use App\Libs\Enums\Http\Status; use App\Libs\Enums\Http\Status;
use App\Libs\Traits\APITraits; use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ResponseInterface as iResponse;
@@ -16,12 +16,8 @@ final class Delete
{ {
use APITraits; use APITraits;
public function __construct(private iDB $db)
{
}
#[\App\Libs\Attributes\Route\Delete(Index::URL . '/{name:backend}[/]', name: 'backend.delete')] #[\App\Libs\Attributes\Route\Delete(Index::URL . '/{name:backend}[/]', name: 'backend.delete')]
public function __invoke(iRequest $request, array $args = []): iResponse public function __invoke(DBLayer $db, iRequest $request, array $args = []): iResponse
{ {
if (null === ($name = ag($args, 'name'))) { if (null === ($name = ag($args, 'name'))) {
return api_error('Invalid value for name path parameter.', Status::BAD_REQUEST); return api_error('Invalid value for name path parameter.', Status::BAD_REQUEST);
@@ -47,13 +43,13 @@ final class Delete
) )
"; ";
$stmt = $this->db->getPDO()->prepare($sql); $stmt = $db->prepare($sql);
$stmt->execute(['name_metadata' => $name, 'name_extra' => $name]); $stmt->execute(['name_metadata' => $name, 'name_extra' => $name]);
$removedReference = $stmt->rowCount(); $removedReference = $stmt->rowCount();
$sql = "DELETE FROM state WHERE id IN ( SELECT id FROM state WHERE length(metadata) < 10 )"; $sql = "DELETE FROM state WHERE id IN ( SELECT id FROM state WHERE length(metadata) < 10 )";
$stmt = $this->db->getPDO()->query($sql); $stmt = $db->query($sql);
$deletedRecords = $stmt->rowCount(); $deletedRecords = $stmt->rowCount();

View File

@@ -10,6 +10,7 @@ use App\Libs\Attributes\Route\Get;
use App\Libs\Attributes\Route\Route; use App\Libs\Attributes\Route\Route;
use App\Libs\Container; use App\Libs\Container;
use App\Libs\Database\DatabaseInterface as iDB; use App\Libs\Database\DatabaseInterface as iDB;
use App\Libs\Database\DBLayer;
use App\Libs\DataUtil; use App\Libs\DataUtil;
use App\Libs\Entity\StateInterface as iState; use App\Libs\Entity\StateInterface as iState;
use App\Libs\Enums\Http\Status; use App\Libs\Enums\Http\Status;
@@ -17,7 +18,6 @@ use App\Libs\Guid;
use App\Libs\Mappers\Import\DirectMapper; use App\Libs\Mappers\Import\DirectMapper;
use App\Libs\Traits\APITraits; use App\Libs\Traits\APITraits;
use JsonException; use JsonException;
use PDO;
use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest; use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\SimpleCache\CacheInterface as iCache; use Psr\SimpleCache\CacheInterface as iCache;
@@ -47,17 +47,15 @@ final class Index
use APITraits; use APITraits;
public const string URL = '%{api.prefix}/history'; public const string URL = '%{api.prefix}/history';
private PDO $pdo;
public function __construct(private readonly iDB $db, private DirectMapper $mapper, private iCache $cache) public function __construct(private readonly iDB $db, private DirectMapper $mapper, private iCache $cache)
{ {
$this->pdo = $this->db->getPDO();
} }
#[Get(self::URL . '[/]', name: 'history.list')] #[Get(self::URL . '[/]', name: 'history.list')]
public function list(iRequest $request): iResponse public function list(DBLayer $db, iRequest $request): iResponse
{ {
$es = fn(string $val) => $this->db->identifier($val); $es = fn(string $val) => $db->escapeIdentifier($val, true);
$data = DataUtil::fromArray($request->getQueryParams()); $data = DataUtil::fromArray($request->getQueryParams());
$filters = []; $filters = [];
@@ -278,7 +276,7 @@ final class Index
$sql[] = 'WHERE ' . implode(' AND ', $where); $sql[] = 'WHERE ' . implode(' AND ', $where);
} }
$stmt = $this->pdo->prepare('SELECT COUNT(*) ' . implode(' ', array_map('trim', $sql))); $stmt = $db->prepare('SELECT COUNT(*) ' . implode(' ', array_map('trim', $sql)));
$stmt->execute($params); $stmt->execute($params);
$total = $stmt->fetchColumn(); $total = $stmt->fetchColumn();
@@ -324,7 +322,7 @@ final class Index
$params['_limit'] = $perpage <= 0 ? 20 : $perpage; $params['_limit'] = $perpage <= 0 ? 20 : $perpage;
$sql[] = 'ORDER BY ' . implode(', ', $sorts) . ' LIMIT :_start,:_limit'; $sql[] = 'ORDER BY ' . implode(', ', $sorts) . ' LIMIT :_start,:_limit';
$stmt = $this->pdo->prepare('SELECT * ' . implode(' ', array_map('trim', $sql))); $stmt = $db->prepare('SELECT * ' . implode(' ', array_map('trim', $sql)));
$stmt->execute($params); $stmt->execute($params);
$getUri = $request->getUri()->withHost('')->withPort(0)->withScheme(''); $getUri = $request->getUri()->withHost('')->withPort(0)->withScheme('');

View File

@@ -10,7 +10,7 @@ use App\Libs\Attributes\Route\Post;
use App\Libs\Config; use App\Libs\Config;
use App\Libs\ConfigFile; use App\Libs\ConfigFile;
use App\Libs\Container; use App\Libs\Container;
use App\Libs\Database\DatabaseInterface as iDB; use App\Libs\Database\DBLayer;
use App\Libs\DataUtil; use App\Libs\DataUtil;
use App\Libs\Entity\StateInterface as iState; use App\Libs\Entity\StateInterface as iState;
use App\Libs\Enums\Http\Status; use App\Libs\Enums\Http\Status;
@@ -26,14 +26,10 @@ final class Index
private array $cache = []; private array $cache = [];
private PDO $db;
private ConfigFile $config; private ConfigFile $config;
public function __construct(iDB $db) public function __construct(private readonly DBLayer $db)
{ {
$this->db = $db->getPDO();
$this->config = ConfigFile::open( $this->config = ConfigFile::open(
file: Config::get('path') . '/config/ignore.yaml', file: Config::get('path') . '/config/ignore.yaml',
type: 'yaml', type: 'yaml',

View File

@@ -7,14 +7,13 @@ namespace App\API\System;
use App\Libs\Attributes\Route\Delete; use App\Libs\Attributes\Route\Delete;
use App\Libs\Attributes\Route\Get; use App\Libs\Attributes\Route\Get;
use App\Libs\Container; use App\Libs\Container;
use App\Libs\Database\DatabaseInterface as iDB; use App\Libs\Database\DBLayer;
use App\Libs\DataUtil; use App\Libs\DataUtil;
use App\Libs\Entity\StateInterface as iState; use App\Libs\Entity\StateInterface as iState;
use App\Libs\Enums\Http\Status; use App\Libs\Enums\Http\Status;
use App\Libs\Middlewares\ExceptionHandlerMiddleware; use App\Libs\Middlewares\ExceptionHandlerMiddleware;
use App\Libs\Traits\APITraits; use App\Libs\Traits\APITraits;
use DateInterval; use DateInterval;
use PDO;
use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest; use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\SimpleCache\CacheInterface as iCache; use Psr\SimpleCache\CacheInterface as iCache;
@@ -31,22 +30,20 @@ final class Integrity
private array $checkedFile = []; private array $checkedFile = [];
private bool $fromCache = false; private bool $fromCache = false;
private PDO $pdo;
/** /**
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
public function __construct(private iDB $db, private readonly iCache $cache) public function __construct(private readonly iCache $cache)
{ {
set_time_limit(0); set_time_limit(0);
$this->pdo = $this->db->getPDO();
} }
/** /**
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
#[Get(self::URL . '[/]', middleware: [ExceptionHandlerMiddleware::class], name: 'system.integrity')] #[Get(self::URL . '[/]', middleware: [ExceptionHandlerMiddleware::class], name: 'system.integrity')]
public function __invoke(iRequest $request): iResponse public function __invoke(DBLayer $db, iRequest $request): iResponse
{ {
$params = DataUtil::fromArray($request->getQueryParams()); $params = DataUtil::fromArray($request->getQueryParams());
@@ -66,7 +63,7 @@ final class Integrity
]; ];
$sql = "SELECT * FROM state"; $sql = "SELECT * FROM state";
$stmt = $this->db->getPDO()->prepare($sql); $stmt = $db->prepare($sql);
$stmt->execute(); $stmt->execute();
$base = Container::get(iState::class); $base = Container::get(iState::class);
@@ -157,7 +154,7 @@ final class Integrity
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
#[Delete(self::URL . '[/]', name: 'system.integrity.reset')] #[Delete(self::URL . '[/]', name: 'system.integrity.reset')]
public function resetCache(iRequest $request): iResponse public function resetCache(): iResponse
{ {
if ($this->cache->has('system.integrity')) { if ($this->cache->has('system.integrity')) {
$this->cache->delete('system.integrity'); $this->cache->delete('system.integrity');

View File

@@ -6,11 +6,10 @@ namespace App\API\System;
use App\Libs\Attributes\Route\Delete; use App\Libs\Attributes\Route\Delete;
use App\Libs\Attributes\Route\Get; use App\Libs\Attributes\Route\Get;
use App\Libs\Database\DatabaseInterface as iDB; use App\Libs\Database\DBLayer;
use App\Libs\DataUtil; use App\Libs\DataUtil;
use App\Libs\Enums\Http\Status; use App\Libs\Enums\Http\Status;
use App\Libs\Traits\APITraits; use App\Libs\Traits\APITraits;
use PDO;
use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest; use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\SimpleCache\InvalidArgumentException; use Psr\SimpleCache\InvalidArgumentException;
@@ -21,11 +20,8 @@ final class Parity
public const string URL = '%{api.prefix}/system/parity'; public const string URL = '%{api.prefix}/system/parity';
private PDO $pdo; public function __construct(private readonly DBLayer $db)
public function __construct(private iDB $db)
{ {
$this->pdo = $this->db->getPDO();
} }
/** /**
@@ -60,7 +56,7 @@ final class Parity
$counter = 0 === $counter ? $backendsCount : $counter; $counter = 0 === $counter ? $backendsCount : $counter;
$sql = "SELECT COUNT(*) FROM state WHERE ( SELECT COUNT(*) FROM JSON_EACH(state.metadata) ) < {$counter}"; $sql = "SELECT COUNT(*) FROM state WHERE ( SELECT COUNT(*) FROM JSON_EACH(state.metadata) ) < {$counter}";
$stmt = $this->pdo->query($sql); $stmt = $this->db->query($sql);
$total = (int)$stmt->fetchColumn(); $total = (int)$stmt->fetchColumn();
$lastPage = @ceil($total / $perpage); $lastPage = @ceil($total / $perpage);
@@ -84,7 +80,7 @@ final class Parity
:_start, :_perpage :_start, :_perpage
"; ";
$stmt = $this->db->getPDO()->prepare($sql); $stmt = $this->db->prepare($sql);
$stmt->execute([ $stmt->execute([
'_start' => $start, '_start' => $start,
'_perpage' => $perpage, '_perpage' => $perpage,
@@ -135,7 +131,7 @@ final class Parity
WHERE WHERE
( SELECT COUNT(*) FROM JSON_EACH(state.metadata) ) < {$counter} ( SELECT COUNT(*) FROM JSON_EACH(state.metadata) ) < {$counter}
"; ";
$stmt = $this->db->getPDO()->query($sql); $stmt = $this->db->query($sql);
return api_response(Status::OK, [ return api_response(Status::OK, [
'deleted_records' => $stmt->rowCount(), 'deleted_records' => $stmt->rowCount(),

View File

@@ -36,6 +36,7 @@ trait CommonTrait
return new Response( return new Response(
status: false, status: false,
error: new Error( error: new Error(
...lw(
message: "{client}: '{backend}' {action} thrown unhandled exception '{error.kind}'. '{error.message}' at '{error.file}:{error.line}'.", message: "{client}: '{backend}' {action} thrown unhandled exception '{error.kind}'. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'action' => $action ?? '', 'action' => $action ?? '',
@@ -56,6 +57,8 @@ trait CommonTrait
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
] ]
], ],
e: $e
),
level: Levels::WARNING, level: Levels::WARNING,
previous: $e previous: $e
) )

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Backends\Common; namespace App\Backends\Common;
use App\Libs\Database\DBLayer;
use App\Libs\Exceptions\DBLayerException;
use Stringable; use Stringable;
use Throwable; use Throwable;
@@ -67,7 +69,17 @@ final readonly class Error implements Stringable
return $this->message; return $this->message;
} }
return r($this->message, $this->context, ['log_behavior' => true]); $context = $this->context;
if (true === ($this->previous instanceof DBLayerException)) {
$context[DBLayer::class] = [
'query' => $this->previous->getQueryString(),
'bind' => $this->previous->getQueryBind(),
'error' => $this->previous->errorInfo ?? [],
];
}
return r($this->message, $context, ['log_behavior' => true]);
} }
public function __toString(): string public function __toString(): string

View File

@@ -307,7 +307,8 @@ final class ParseWebhook
], ],
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
level: Levels::ERROR level: Levels::ERROR,
previous: $e
), ),
extra: [ extra: [
'http_code' => 200, 'http_code' => 200,

View File

@@ -226,8 +226,9 @@ class Progress
} }
} catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) { } catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.", message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.",
contexT: [ context: [
'action' => $this->action, 'action' => $this->action,
'client' => $context->clientName, 'client' => $context->clientName,
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -245,7 +246,9 @@ class Progress
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
continue; continue;
} }
@@ -293,6 +296,7 @@ class Progress
} }
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.", message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'action' => $this->action, 'action' => $this->action,
@@ -312,7 +316,9 @@ class Progress
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
} }
} }

View File

@@ -91,11 +91,17 @@ class Export extends Import
'type' => $type, 'type' => $type,
]; ];
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
$this->logger->info($e->getMessage(), [ $this->logger->info(
...lw(
message: $e->getMessage(),
context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
...$logContext, ...$logContext,
'body' => $item, 'body' => $item,
]); ],
e: $e
)
);
return; return;
} }
@@ -257,6 +263,7 @@ class Export extends Import
); );
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] export. Error [{error.message} @ {error.file}:{error.line}].', message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] export. Error [{error.message} @ {error.file}:{error.line}].',
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -275,7 +282,9 @@ class Export extends Import
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
} }
} }

View File

@@ -77,7 +77,10 @@ class GetLibrariesList
$this->logger $this->logger
); );
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
return new Response(status: false, error: new Error(message: $e->getMessage(), level: Levels::ERROR)); return new Response(
status: false,
error: new Error(message: $e->getMessage(), level: Levels::ERROR, previous: $e)
);
} }
if ($context->trace) { if ($context->trace) {

View File

@@ -192,6 +192,7 @@ class Import
} }
} catch (ExceptionInterface $e) { } catch (ExceptionInterface $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Request for '{client}: {backend}' libraries has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", message: "Request for '{client}: {backend}' libraries has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'client' => $context->clientName, 'client' => $context->clientName,
@@ -209,12 +210,15 @@ class Import
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
Message::add("{$context->backendName}.has_errors", true); Message::add("{$context->backendName}.has_errors", true);
return []; return [];
} catch (JsonException $e) { } catch (JsonException $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Request for '{client}: {backend}' libraries returned with invalid body. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", message: "Request for '{client}: {backend}' libraries returned with invalid body. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'client' => $context->clientName, 'client' => $context->clientName,
@@ -231,12 +235,15 @@ class Import
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
Message::add("{$context->backendName}.has_errors", true); Message::add("{$context->backendName}.has_errors", true);
return []; return [];
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -254,7 +261,9 @@ class Import
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
Message::add("{$context->backendName}.has_errors", true); Message::add("{$context->backendName}.has_errors", true);
return []; return [];
@@ -319,6 +328,7 @@ class Import
); );
} catch (ExceptionInterface $e) { } catch (ExceptionInterface $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Request for '{client}: {backend}' '{library.title}' items count failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", message: "Request for '{client}: {backend}' '{library.title}' items count failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'client' => $context->clientName, 'client' => $context->clientName,
@@ -337,11 +347,14 @@ class Import
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
continue; continue;
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' items count request. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' items count request. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'client' => $context->clientName, 'client' => $context->clientName,
@@ -360,7 +373,9 @@ class Import
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
continue; continue;
} }
@@ -404,6 +419,7 @@ class Import
$total[ag($logContext, 'library.id')] = $totalCount; $total[ag($logContext, 'library.id')] = $totalCount;
} catch (ExceptionInterface $e) { } catch (ExceptionInterface $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Request for '{client}: {backend}' '{library.title}' total items has failed. '{error.kind}' '{error.message}' at '{error.file}:{error.line}'.", message: "Request for '{client}: {backend}' '{library.title}' total items has failed. '{error.kind}' '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'client' => $context->backendName, 'client' => $context->backendName,
@@ -422,12 +438,14 @@ class Import
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
...$logContext, ...$logContext,
],
] e: $e
),
); );
continue; continue;
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' requests for items count. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' requests for items count. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'client' => $context->clientName, 'client' => $context->clientName,
@@ -446,7 +464,9 @@ class Import
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
continue; continue;
} }
@@ -515,6 +535,7 @@ class Import
); );
} catch (ExceptionInterface $e) { } catch (ExceptionInterface $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Request for '{client}: {backend}' '{library.title}' series external ids has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", message: "Request for '{client}: {backend}' '{library.title}' series external ids has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'client' => $context->clientName, 'client' => $context->clientName,
@@ -533,11 +554,14 @@ class Import
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
continue; continue;
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' series external ids request. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' series external ids request. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -556,7 +580,9 @@ class Import
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
...$logContext, ...$logContext,
] ],
e: $e
),
); );
continue; continue;
} }
@@ -663,8 +689,9 @@ class Import
); );
} catch (ExceptionInterface $e) { } catch (ExceptionInterface $e) {
$this->logger->error( $this->logger->error(
"Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list has failed. {error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", ...lw(
[ message: "Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list has failed. {error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
context: [
'client' => $context->clientName, 'client' => $context->clientName,
'backend' => $context->backendName, 'backend' => $context->backendName,
'error' => [ 'error' => [
@@ -681,11 +708,14 @@ class Import
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
continue; continue;
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list request. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list request. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -704,7 +734,9 @@ class Import
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
continue; continue;
} }
@@ -850,6 +882,7 @@ class Import
} }
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing '{library.title} {segment.number}/{segment.of}' item response. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing '{library.title} {segment.number}/{segment.of}' item response. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -869,12 +902,15 @@ class Import
'file' => after($e->getFile(), ROOT_PATH), 'file' => after($e->getFile(), ROOT_PATH),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
} }
} }
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing of '{library.title} {segment.number}/{segment.of}' response. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing of '{library.title} {segment.number}/{segment.of}' response. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -893,7 +929,9 @@ class Import
'file' => after($e->getFile(), ROOT_PATH), 'file' => after($e->getFile(), ROOT_PATH),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
} }
@@ -1035,8 +1073,9 @@ class Import
]; ];
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
$this->logger->error( $this->logger->error(
"Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ", ...lw(
[ message: "Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ",
context: [
'client' => $context->clientName, 'client' => $context->clientName,
'backend' => $context->backendName, 'backend' => $context->backendName,
'error' => [ 'error' => [
@@ -1047,7 +1086,9 @@ class Import
], ],
'body' => $item, 'body' => $item,
...$logContext, ...$logContext,
] ],
e: $e
)
); );
return; return;
} }
@@ -1110,6 +1151,7 @@ class Import
); );
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' occurred during '{client}: {backend}' '{library.title}' '{item.id}: {item.title}' entity creation. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' occurred during '{client}: {backend}' '{library.title}' '{item.id}: {item.title}' entity creation. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -1121,7 +1163,9 @@ class Import
'file' => after($e->getFile(), ROOT_PATH), 'file' => after($e->getFile(), ROOT_PATH),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
Message::increment("{$context->backendName}.{$mappedType}.ignored_no_date_is_set"); Message::increment("{$context->backendName}.{$mappedType}.ignored_no_date_is_set");
@@ -1155,6 +1199,7 @@ class Import
$mapper->add(entity: $entity, opts: $opts); $mapper->add(entity: $entity, opts: $opts);
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' '{item.title}' {action}. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' '{item.title}' {action}. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'action' => property_exists($this, 'action') ? $this->action : 'import', 'action' => property_exists($this, 'action') ? $this->action : 'import',
@@ -1174,7 +1219,9 @@ class Import
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
} }
} }

View File

@@ -265,7 +265,8 @@ final class ParseWebhook
'payload' => $request->getParsedBody(), 'payload' => $request->getParsedBody(),
], ],
], ],
level: Levels::ERROR level: Levels::ERROR,
previous: $e
), ),
extra: [ extra: [
'http_code' => 200, 'http_code' => 200,

View File

@@ -254,6 +254,7 @@ class Progress
} }
} catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) { } catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.", message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'action' => $this->action, 'action' => $this->action,
@@ -273,7 +274,9 @@ class Progress
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
),
); );
continue; continue;
} }
@@ -321,6 +324,7 @@ class Progress
} }
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.", message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'action' => $this->action, 'action' => $this->action,
@@ -340,7 +344,9 @@ class Progress
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
} }
} }

View File

@@ -145,6 +145,7 @@ class Push
); );
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] request for {item.type} [{item.title}] metadata. Error [{error.message} @ {error.file}:{error.line}].', message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] request for {item.type} [{item.title}] metadata. Error [{error.message} @ {error.file}:{error.line}].',
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -163,7 +164,9 @@ class Push
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
} }
} }
@@ -327,6 +330,7 @@ class Push
} }
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] parsing [{library.title}] [{segment.number}/{segment.of}] response. Error [{error.message} @ {error.file}:{error.line}].', message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] parsing [{library.title}] [{segment.number}/{segment.of}] response. Error [{error.message} @ {error.file}:{error.line}].',
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -345,7 +349,9 @@ class Push
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
} }
} }

View File

@@ -76,8 +76,9 @@ final class Export extends Import
]; ];
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
$this->logger->error( $this->logger->error(
"Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ", ...lw(
[ message: "Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ",
context: [
'client' => $context->clientName, 'client' => $context->clientName,
'backend' => $context->backendName, 'backend' => $context->backendName,
'error' => [ 'error' => [
@@ -88,7 +89,9 @@ final class Export extends Import
], ],
...$logContext, ...$logContext,
'body' => $item, 'body' => $item,
] ],
e: $e
)
); );
return; return;
} }
@@ -242,6 +245,7 @@ final class Export extends Import
]))); ])));
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' export. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' export. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -260,7 +264,9 @@ final class Export extends Import
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
} }
} }

View File

@@ -66,7 +66,10 @@ final class GetLibrariesList
$this->logger $this->logger
); );
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
return new Response(status: false, error: new Error(message: $e->getMessage(), level: Levels::ERROR)); return new Response(
status: false,
error: new Error(message: $e->getMessage(), level: Levels::ERROR, previous: $e)
);
} }
if ($context->trace) { if ($context->trace) {
@@ -82,7 +85,7 @@ final class GetLibrariesList
return new Response( return new Response(
status: false, status: false,
error: new Error( error: new Error(
message: 'Request for [{backend}] libraries returned empty list.', message: "Request for '{backend}' libraries returned empty list.",
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
'response' => [ 'response' => [
@@ -153,7 +156,7 @@ final class GetLibrariesList
{ {
$url = $context->backendUrl->withPath('/library/sections'); $url = $context->backendUrl->withPath('/library/sections');
$this->logger->debug('Requesting [{backend}] libraries list.', [ $this->logger->debug("Requesting '{backend}' libraries list.", [
'backend' => $context->backendName, 'backend' => $context->backendName,
'url' => (string)$url 'url' => (string)$url
]); ]);
@@ -163,7 +166,7 @@ final class GetLibrariesList
$payload = $response->getContent(false); $payload = $response->getContent(false);
if ($context->trace) { if ($context->trace) {
$this->logger->debug('Processing [{backend}] response.', [ $this->logger->debug("Processing '{backend}' response.", [
'backend' => $context->backendName, 'backend' => $context->backendName,
'url' => (string)$url, 'url' => (string)$url,
'response' => $payload, 'response' => $payload,
@@ -172,13 +175,10 @@ final class GetLibrariesList
if (200 !== $response->getStatusCode()) { if (200 !== $response->getStatusCode()) {
throw new RuntimeException( throw new RuntimeException(
r( r("Request for '{backend}' libraries returned with unexpected '{status_code}' status code.", [
'Request for [{backend}] libraries returned with unexpected [{status_code}] status code.',
[
'backend' => $context->backendName, 'backend' => $context->backendName,
'status_code' => $response->getStatusCode(), 'status_code' => $response->getStatusCode(),
] ])
)
); );
} }

View File

@@ -246,7 +246,8 @@ final class GetUserToken
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
], ],
level: Levels::ERROR level: Levels::ERROR,
previous: $e
), ),
); );
} }

View File

@@ -173,6 +173,7 @@ class Import
} }
} catch (ExceptionInterface $e) { } catch (ExceptionInterface $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Request for '{client}: {backend}' libraries has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", message: "Request for '{client}: {backend}' libraries has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'client' => $context->clientName, 'client' => $context->clientName,
@@ -190,12 +191,15 @@ class Import
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
Message::add("{$context->backendName}.has_errors", true); Message::add("{$context->backendName}.has_errors", true);
return []; return [];
} catch (JsonException $e) { } catch (JsonException $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Request for '{client}: {backend}' libraries returned with invalid body. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", message: "Request for '{client}: {backend}' libraries returned with invalid body. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'client' => $context->clientName, 'client' => $context->clientName,
@@ -212,12 +216,15 @@ class Import
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
Message::add("{$context->backendName}.has_errors", true); Message::add("{$context->backendName}.has_errors", true);
return []; return [];
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -235,7 +242,9 @@ class Import
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
Message::add("{$context->backendName}.has_errors", true); Message::add("{$context->backendName}.has_errors", true);
return []; return [];
@@ -351,6 +360,7 @@ class Import
} }
} catch (ExceptionInterface $e) { } catch (ExceptionInterface $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Request for '{client}: {backend}' '{library.title}' items count has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", message: "Request for '{client}: {backend}' '{library.title}' items count has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'client' => $context->clientName, 'client' => $context->clientName,
@@ -369,11 +379,14 @@ class Import
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
continue; continue;
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for libraries. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'client' => $context->clientName, 'client' => $context->clientName,
@@ -392,7 +405,9 @@ class Import
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
continue; continue;
} }
@@ -438,6 +453,7 @@ class Import
} }
} catch (ExceptionInterface $e) { } catch (ExceptionInterface $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Request for '{client}: {backend}' '{library.title}' total items has failed. '{error.kind}' '{error.message}' at '{error.file}:{error.line}'.", message: "Request for '{client}: {backend}' '{library.title}' total items has failed. '{error.kind}' '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'client' => $context->backendName, 'client' => $context->backendName,
@@ -456,11 +472,14 @@ class Import
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
continue; continue;
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for items count. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' request for items count. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'client' => $context->clientName, 'client' => $context->clientName,
@@ -479,7 +498,9 @@ class Import
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
continue; continue;
} }
@@ -576,6 +597,7 @@ class Import
); );
} catch (ExceptionInterface $e) { } catch (ExceptionInterface $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' series external ids has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", message: "Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' series external ids has failed. '{error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'client' => $context->clientName, 'client' => $context->clientName,
@@ -594,11 +616,14 @@ class Import
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
continue; continue;
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' series external ids request. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' series external ids request. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -617,7 +642,9 @@ class Import
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
continue; continue;
} }
@@ -732,8 +759,9 @@ class Import
); );
} catch (ExceptionInterface $e) { } catch (ExceptionInterface $e) {
$this->logger->error( $this->logger->error(
"Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list has failed. {error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.", ...lw(
[ message: "Request for '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list has failed. {error.kind}' with message '{error.message}' at '{error.file}:{error.line}'.",
context: [
'client' => $context->clientName, 'client' => $context->clientName,
'backend' => $context->backendName, 'backend' => $context->backendName,
'error' => [ 'error' => [
@@ -750,11 +778,14 @@ class Import
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
continue; continue;
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list request. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title} {segment.number}/{segment.of}' content list request. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -773,7 +804,9 @@ class Import
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
continue; continue;
} }
@@ -863,6 +896,7 @@ class Import
$callback(item: $entity, logContext: $logContext); $callback(item: $entity, logContext: $logContext);
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing '{library.title} {segment.number}/{segment.of}' item response. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing '{library.title} {segment.number}/{segment.of}' item response. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -882,12 +916,15 @@ class Import
'file' => after($e->getFile(), ROOT_PATH), 'file' => after($e->getFile(), ROOT_PATH),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
} }
} }
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing of '{library.title} {segment.number}/{segment.of}' response. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing of '{library.title} {segment.number}/{segment.of}' response. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -906,7 +943,9 @@ class Import
'file' => after($e->getFile(), ROOT_PATH), 'file' => after($e->getFile(), ROOT_PATH),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
} }
@@ -1051,8 +1090,9 @@ class Import
]; ];
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
$this->logger->error( $this->logger->error(
"Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ", ...lw(
[ message: "Failed to parse '{client}: {backend}' item response. '{error.kind}' with '{error.message}' at '{error.file}:{error.line}' ",
context: [
'client' => $context->clientName, 'client' => $context->clientName,
'backend' => $context->backendName, 'backend' => $context->backendName,
'error' => [ 'error' => [
@@ -1063,7 +1103,9 @@ class Import
], ],
...$logContext, ...$logContext,
'body' => $item, 'body' => $item,
] ],
e: $e
)
); );
return; return;
} }
@@ -1103,6 +1145,7 @@ class Import
); );
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' occurred during '{client}: {backend}' '{library.title}' '{item.id}: {item.title}' entity creation. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' occurred during '{client}: {backend}' '{library.title}' '{item.id}: {item.title}' entity creation. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -1114,7 +1157,9 @@ class Import
'file' => after($e->getFile(), ROOT_PATH), 'file' => after($e->getFile(), ROOT_PATH),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
return; return;
} }
@@ -1151,6 +1196,7 @@ class Import
]); ]);
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' '{item.title}' {action}. '{error.message}' at '{error.file}:{error.line}'.", message: "Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' '{library.title}' '{item.title}' {action}. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'action' => property_exists($this, 'action') ? $this->action : 'import', 'action' => property_exists($this, 'action') ? $this->action : 'import',
@@ -1170,7 +1216,9 @@ class Import
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
...$logContext, ...$logContext,
] ],
e: $e
)
); );
} }
} }

View File

@@ -230,6 +230,7 @@ class Progress
} }
} catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) { } catch (\App\Libs\Exceptions\RuntimeException|RuntimeException|InvalidArgumentException $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.", message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' get {item.type} '{item.title}' status. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'action' => $this->action, 'action' => $this->action,
@@ -249,7 +250,9 @@ class Progress
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
continue; continue;
} }
@@ -305,6 +308,7 @@ class Progress
} }
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.", message: "{action}: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' change {item.type} '{item.title}' watch progress. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'action' => $this->action, 'action' => $this->action,
@@ -324,7 +328,9 @@ class Progress
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
} }
} }

View File

@@ -115,6 +115,7 @@ final class Push
); );
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] request for {item.type} [{item.title}] metadata. Error [{error.message} @ {error.file}:{error.line}].', message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] request for {item.type} [{item.title}] metadata. Error [{error.message} @ {error.file}:{error.line}].',
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -133,7 +134,9 @@ final class Push
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
} }
} }
@@ -308,6 +311,7 @@ final class Push
} }
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
...lw(
message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] push. Error [{error.message} @ {error.file}:{error.line}].', message: 'Exception [{error.kind}] was thrown unhandled during [{client}: {backend}] push. Error [{error.message} @ {error.file}:{error.line}].',
context: [ context: [
'backend' => $context->backendName, 'backend' => $context->backendName,
@@ -326,7 +330,9 @@ final class Push
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
], ],
] ],
e: $e
)
); );
} }
} }

View File

@@ -103,7 +103,7 @@ final class ReportCommand extends Command
$output->writeln( $output->writeln(
r('Is the tasks runner working? <flag>{answer}</flag>', [ r('Is the tasks runner working? <flag>{answer}</flag>', [
'answer' => (function () { 'answer' => (function () {
$info = isTaskWorkerRunning(true); $info = isTaskWorkerRunning(ignoreContainer: true);
return r("{status} '{container}' - {message}", [ return r("{status} '{container}' - {message}", [
'status' => $info['status'] ? 'Yes' : 'No', 'status' => $info['status'] ? 'Yes' : 'No',
'message' => $info['message'], 'message' => $info['message'],
@@ -246,7 +246,7 @@ final class ReportCommand extends Command
if (true === $includeSample) { if (true === $includeSample) {
$sql = "SELECT * FROM state WHERE via = :name ORDER BY updated DESC LIMIT 3"; $sql = "SELECT * FROM state WHERE via = :name ORDER BY updated DESC LIMIT 3";
$stmt = $this->db->getPDO()->prepare($sql); $stmt = $this->db->getDBLayer()->prepare($sql);
$stmt->execute([ $stmt->execute([
'name' => $name, 'name' => $name,
]); ]);

View File

@@ -161,7 +161,7 @@ final class ConfigFile implements ArrayAccess, LoggerAwareInterface
if (false === $override) { if (false === $override) {
clearstatcache(true, $this->file); clearstatcache(true, $this->file);
$newHash = $this->getFileHash(); $newHash = $this->getFileHash();
if ($newHash !== $this->file_hash) { if (false === hash_equals($this->file_hash, $newHash)) {
$this->logger?->warning( $this->logger?->warning(
"File '{file}' has been modified since last load. re-applying changes on top of the new data.", "File '{file}' has been modified since last load. re-applying changes on top of the new data.",
[ [

View File

@@ -5,15 +5,19 @@ declare(strict_types=1);
namespace App\Libs\Database; namespace App\Libs\Database;
use App\Libs\Exceptions\DatabaseException as DBException; use App\Libs\Exceptions\DBLayerException;
use Closure; use Closure;
use PDO; use PDO;
use PDOException; use PDOException;
use PDOStatement; use PDOStatement;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use RuntimeException; use RuntimeException;
final class DBLayer final class DBLayer implements LoggerAwareInterface
{ {
use LoggerAwareTrait;
private const int LOCK_RETRY = 4; private const int LOCK_RETRY = 4;
private int $count = 0; private int $count = 0;
@@ -61,6 +65,7 @@ final class DBLayer
public function exec(string $sql, array $options = []): int|false public function exec(string $sql, array $options = []): int|false
{ {
try { try {
return $this->wrap(function (DBLayer $db) use ($sql, $options) {
$queryString = $sql; $queryString = $sql;
$this->last = [ $this->last = [
@@ -68,29 +73,35 @@ final class DBLayer
'bind' => [], 'bind' => [],
]; ];
$stmt = $this->pdo->exec($queryString); return $db->pdo->exec($queryString);
});
} catch (PDOException $e) { } catch (PDOException $e) {
throw (new DBException($e->getMessage())) if ($e instanceof DBLayerException) {
->setInfo($queryString, [], $e->errorInfo ?? [], $e->getCode()) throw $e;
}
throw (new DBLayerException($e->getMessage()))
->setInfo($sql, [], $e->errorInfo ?? [], $e->getCode())
->setFile($e->getTrace()[$options['tracer'] ?? 1]['file'] ?? $e->getFile()) ->setFile($e->getTrace()[$options['tracer'] ?? 1]['file'] ?? $e->getFile())
->setLine($e->getTrace()[$options['tracer'] ?? 1]['line'] ?? $e->getLine()) ->setLine($e->getTrace()[$options['tracer'] ?? 1]['line'] ?? $e->getLine())
->setOptions([]); ->setOptions($options);
}
} }
return $stmt; public function query(string|PDOStatement $sql, array $bind = [], array $options = []): PDOStatement
}
public function query(string $queryString, array $bind = [], array $options = []): PDOStatement
{ {
try { try {
return $this->wrap(function (DBLayer $db) use ($sql, $bind, $options) {
$isStatement = $sql instanceof PDOStatement;
$queryString = $isStatement ? $sql->queryString : $sql;
$this->last = [ $this->last = [
'sql' => $queryString, 'sql' => $queryString,
'bind' => $bind, 'bind' => $bind,
]; ];
$stmt = $this->pdo->prepare($queryString); $stmt = $isStatement ? $sql : $db->prepare($sql);
if (false === ($stmt instanceof PDOStatement)) {
if (!($stmt instanceof PDOStatement)) {
throw new PDOException('Unable to prepare statement.'); throw new PDOException('Unable to prepare statement.');
} }
@@ -101,15 +112,24 @@ final class DBLayer
$this->count = (int)$countStatement->fetch(PDO::FETCH_COLUMN); $this->count = (int)$countStatement->fetch(PDO::FETCH_COLUMN);
} }
} }
return $stmt;
});
} catch (PDOException $e) { } catch (PDOException $e) {
throw (new DBException($e->getMessage())) if ($e instanceof DBLayerException) {
->setInfo($queryString, $bind, $e->errorInfo ?? [], $e->getCode()) 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()) ->setFile($e->getTrace()[$options['tracer'] ?? 1]['file'] ?? $e->getFile())
->setLine($e->getTrace()[$options['tracer'] ?? 1]['line'] ?? $e->getLine()) ->setLine($e->getTrace()[$options['tracer'] ?? 1]['line'] ?? $e->getLine())
->setOptions($options); ->setOptions($options);
} }
return $stmt;
} }
public function start(): bool public function start(): bool
@@ -136,6 +156,25 @@ final class DBLayer
return $this->pdo->inTransaction(); return $this->pdo->inTransaction();
} }
/**
* @return bool
* @deprecated Use {@link self::start()} instead.
*/
public function beginTransaction(): bool
{
return $this->start();
}
public function prepare(string $sql, array $options = []): PDOStatement|false
{
return $this->pdo->prepare($sql, $options);
}
public function lastInsertId(): string|false
{
return $this->pdo->lastInsertId();
}
public function delete(string $table, array $conditions, array $options = []): PDOStatement public function delete(string $table, array $conditions, array $options = []): PDOStatement
{ {
if (empty($conditions)) { if (empty($conditions)) {
@@ -422,6 +461,11 @@ final class DBLayer
return $this->driver; return $this->driver;
} }
public function getBackend(): PDO
{
return $this->pdo;
}
private function conditionParser(array $conditions): array private function conditionParser(array $conditions): array
{ {
$keys = $bind = []; $keys = $bind = [];
@@ -699,21 +743,22 @@ final class DBLayer
for ($i = 1; $i <= self::LOCK_RETRY; $i++) { for ($i = 1; $i <= self::LOCK_RETRY; $i++) {
try { try {
if (!$autoStartTransaction) { if (true === $autoStartTransaction) {
$this->start(); $this->start();
} }
$result = $callback($this); $result = $callback($this);
if (!$autoStartTransaction) { if (true === $autoStartTransaction) {
$this->commit(); $this->commit();
} }
$this->last = $this->getLastStatement(); $this->last = $this->getLastStatement();
return $result; return $result;
} catch (DBException $e) { } catch (DBLayerException $e) {
if (!$autoStartTransaction && $this->inTransaction()) { /** @noinspection PhpConditionAlreadyCheckedInspection */
if ($autoStartTransaction && $this->inTransaction()) {
$this->rollBack(); $this->rollBack();
} }
@@ -737,4 +782,37 @@ final class DBLayer
*/ */
return null; 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 {
throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e))
->setInfo($stnt->queryString, $bind, $e->errorInfo ?? [], $e->getCode())
->setFile($e->getFile())
->setLine($e->getLine());
}
}
}
return false;
}
} }

View File

@@ -7,10 +7,8 @@ namespace App\Libs\Database;
use App\Libs\Entity\StateInterface; use App\Libs\Entity\StateInterface;
use Closure; use Closure;
use DateTimeInterface; use DateTimeInterface;
use PDO;
use PDOException; use PDOException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use RuntimeException;
interface DatabaseInterface interface DatabaseInterface
{ {
@@ -55,15 +53,6 @@ interface DatabaseInterface
*/ */
public function getAll(DateTimeInterface|null $date = null, array $opts = []): array; public function getAll(DateTimeInterface|null $date = null, array $opts = []): array;
/**
* Return number of items.
*
* @param DateTimeInterface|null $date if provided, it will return items changes since this date.
*
* @return int Number of items.
*/
public function getCount(DateTimeInterface|null $date = null): int;
/** /**
* Return database records for given items. * Return database records for given items.
* *
@@ -184,13 +173,11 @@ interface DatabaseInterface
public function setLogger(LoggerInterface $logger): self; public function setLogger(LoggerInterface $logger): self;
/** /**
* Get PDO instance. * Get DBLayer instance.
* *
* @return PDO * @return DBLayer
*
* @throws RuntimeException if PDO is not initialized yet.
*/ */
public function getPDO(): PDO; public function getDBLayer(): DBLayer;
/** /**
* Enable single transaction mode. * Enable single transaction mode.

View File

@@ -6,8 +6,10 @@ namespace App\Libs\Database\PDO;
use App\Libs\Container; use App\Libs\Container;
use App\Libs\Database\DatabaseInterface as iDB; use App\Libs\Database\DatabaseInterface as iDB;
use App\Libs\Database\DBLayer;
use App\Libs\Entity\StateInterface as iState; use App\Libs\Entity\StateInterface as iState;
use App\Libs\Exceptions\DatabaseException as DBException; use App\Libs\Exceptions\DBAdapterException as DBException;
use App\Libs\Exceptions\DBLayerException;
use App\Libs\Options; use App\Libs\Options;
use Closure; use Closure;
use DateTimeInterface; use DateTimeInterface;
@@ -34,14 +36,17 @@ final class PDOAdapter implements iDB
* @var bool Whether the current operation is in a transaction. * @var bool Whether the current operation is in a transaction.
*/ */
private bool $viaTransaction = false; private bool $viaTransaction = false;
/** /**
* @var bool Whether the current operation is using a single transaction. * @var bool Whether the current operation is using a single transaction.
*/ */
private bool $singleTransaction = false; private bool $singleTransaction = false;
/** /**
* @var array Adapter options. * @var array Adapter options.
*/ */
private array $options = []; private array $options = [];
/** /**
* @var array<array-key, PDOStatement> Prepared statements. * @var array<array-key, PDOStatement> Prepared statements.
*/ */
@@ -49,6 +54,7 @@ final class PDOAdapter implements iDB
'insert' => null, 'insert' => null,
'update' => null, 'update' => null,
]; ];
/** /**
* @var string The database driver to be used. * @var string The database driver to be used.
*/ */
@@ -58,15 +64,11 @@ final class PDOAdapter implements iDB
* Creates a new instance of the class. * Creates a new instance of the class.
* *
* @param LoggerInterface $logger The logger object used for logging. * @param LoggerInterface $logger The logger object used for logging.
* @param PDO $pdo The PDO object used for database connections. * @param DBLayer $db The PDO object used for database connections.
*/ */
public function __construct(private LoggerInterface $logger, private PDO $pdo) public function __construct(private LoggerInterface $logger, private readonly DBLayer $db)
{ {
$driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); $this->driver = $this->db->getDriver();
if (is_string($driver)) {
$this->driver = $driver;
}
} }
/** /**
@@ -151,14 +153,14 @@ final class PDOAdapter implements iDB
} }
if (null === ($this->stmt['insert'] ?? null)) { if (null === ($this->stmt['insert'] ?? null)) {
$this->stmt['insert'] = $this->pdo->prepare( $this->stmt['insert'] = $this->db->prepare(
$this->pdoInsert('state', iState::ENTITY_KEYS) $this->pdoInsert('state', iState::ENTITY_KEYS)
); );
} }
$this->execute($this->stmt['insert'], $data); $this->execute($this->stmt['insert'], $data);
$entity->id = (int)$this->pdo->lastInsertId(); $entity->id = (int)$this->db->lastInsertId();
} catch (PDOException $e) { } catch (PDOException $e) {
$this->stmt['insert'] = null; $this->stmt['insert'] = null;
if (false === $this->viaTransaction && false === $this->singleTransaction) { if (false === $this->viaTransaction && false === $this->singleTransaction) {
@@ -282,21 +284,6 @@ final class PDOAdapter implements iDB
return $arr; return $arr;
} }
/**
* @inheritdoc
* @throws RandomException if an error occurs while generating a random number.
*/
public function getCount(DateTimeInterface|null $date = null): int
{
$sql = 'SELECT COUNT(id) AS total FROM state';
if (null !== $date) {
$sql .= ' WHERE ' . iState::COLUMN_UPDATED . ' > ' . $date->getTimestamp();
}
return (int)$this->query($sql)->fetchColumn();
}
/** /**
* @inheritdoc * @inheritdoc
* @throws RandomException if an error occurs while generating a random number. * @throws RandomException if an error occurs while generating a random number.
@@ -334,7 +321,7 @@ final class PDOAdapter implements iDB
} }
$sql = "SELECT * FROM state WHERE {$type_sql} JSON_EXTRACT(" . iState::COLUMN_META_DATA . ",'$.{$key}') = :id LIMIT 1"; $sql = "SELECT * FROM state WHERE {$type_sql} JSON_EXTRACT(" . iState::COLUMN_META_DATA . ",'$.{$key}') = :id LIMIT 1";
$stmt = $this->pdo->prepare($sql); $stmt = $this->db->prepare($sql);
if (false === $this->execute($stmt, $cond)) { if (false === $this->execute($stmt, $cond)) {
throw new DBException( throw new DBException(
@@ -404,7 +391,7 @@ final class PDOAdapter implements iDB
} }
if (null === ($this->stmt['update'] ?? null)) { if (null === ($this->stmt['update'] ?? null)) {
$this->stmt['update'] = $this->pdo->prepare( $this->stmt['update'] = $this->db->prepare(
$this->pdoUpdate('state', iState::ENTITY_KEYS) $this->pdoUpdate('state', iState::ENTITY_KEYS)
); );
} }
@@ -554,16 +541,14 @@ final class PDOAdapter implements iDB
*/ */
public function migrations(string $dir, array $opts = []): mixed public function migrations(string $dir, array $opts = []): mixed
{ {
$class = new PDOMigrations($this->pdo, $this->logger); $class = new PDOMigrations($this->db, $this->logger);
return match (strtolower($dir)) { return match (strtolower($dir)) {
iDB::MIGRATE_UP => $class->up(), iDB::MIGRATE_UP => $class->up(),
iDB::MIGRATE_DOWN => $class->down(), iDB::MIGRATE_DOWN => $class->down(),
default => throw new DBException( default => throw new DBException(r("PDOAdapter: Unknown migration direction '{dir}' was given.", [
r("PDOAdapter: Unknown migration direction '{dir}' was given.", [
'name' => $dir 'name' => $dir
]), 91 ]), 91),
),
}; };
} }
@@ -572,7 +557,7 @@ final class PDOAdapter implements iDB
*/ */
public function ensureIndex(array $opts = []): mixed public function ensureIndex(array $opts = []): mixed
{ {
return (new PDOIndexer($this->pdo, $this->logger))->ensureIndex($opts); return (new PDOIndexer($this->db, $this->logger))->ensureIndex($opts);
} }
/** /**
@@ -580,7 +565,7 @@ final class PDOAdapter implements iDB
*/ */
public function migrateData(string $version, LoggerInterface|null $logger = null): mixed public function migrateData(string $version, LoggerInterface|null $logger = null): mixed
{ {
return (new PDODataMigration($this->pdo, $logger ?? $this->logger))->automatic(); return (new PDODataMigration($this->db, $logger ?? $this->logger))->automatic();
} }
/** /**
@@ -588,7 +573,7 @@ final class PDOAdapter implements iDB
*/ */
public function isMigrated(): bool public function isMigrated(): bool
{ {
return (new PDOMigrations($this->pdo, $this->logger))->isMigrated(); return (new PDOMigrations($this->db, $this->logger))->isMigrated();
} }
/** /**
@@ -596,7 +581,7 @@ final class PDOAdapter implements iDB
*/ */
public function makeMigration(string $name, array $opts = []): mixed public function makeMigration(string $name, array $opts = []): mixed
{ {
return (new PDOMigrations($this->pdo, $this->logger))->make($name); return (new PDOMigrations($this->db, $this->logger))->make($name);
} }
/** /**
@@ -604,7 +589,7 @@ final class PDOAdapter implements iDB
*/ */
public function maintenance(array $opts = []): mixed public function maintenance(array $opts = []): mixed
{ {
return (new PDOMigrations($this->pdo, $this->logger))->runMaintenance(); return (new PDOMigrations($this->db, $this->logger))->runMaintenance();
} }
/** /**
@@ -613,19 +598,19 @@ final class PDOAdapter implements iDB
*/ */
public function reset(): bool public function reset(): bool
{ {
$this->pdo->beginTransaction(); $this->db->transactional(function (DBLayer $db) {
/** @noinspection SqlResolve */
$tables = $this->pdo->query( $tables = $db->query(
'SELECT name FROM sqlite_master WHERE "type" = "table" AND "name" NOT LIKE "sqlite_%"' 'SELECT name FROM sqlite_master WHERE "type" = "table" AND "name" NOT LIKE "sqlite_%"'
); );
foreach ($tables->fetchAll(PDO::FETCH_COLUMN) as $table) { foreach ($tables->fetchAll(PDO::FETCH_COLUMN) as $table) {
$this->pdo->exec('DELETE FROM "' . $table . '"'); $db->exec('DELETE FROM "' . $table . '"');
$this->pdo->exec('DELETE FROM sqlite_sequence WHERE "name" = "' . $table . '"'); $db->exec('DELETE FROM sqlite_sequence WHERE "name" = "' . $table . '"');
} }
});
$this->pdo->commit(); $this->db->exec('VACUUM');
$this->pdo->exec('VACUUM');
return true; return true;
} }
@@ -640,12 +625,9 @@ final class PDOAdapter implements iDB
return $this; return $this;
} }
/** public function getDBLayer(): DBLayer
* @inheritdoc
*/
public function getPDO(): PDO
{ {
return $this->pdo; return $this->db;
} }
/** /**
@@ -655,11 +637,11 @@ final class PDOAdapter implements iDB
{ {
$this->singleTransaction = true; $this->singleTransaction = true;
if (false === $this->pdo->inTransaction()) { if (false === $this->db->inTransaction()) {
$this->pdo->beginTransaction(); $this->db->start();
} }
return $this->pdo->inTransaction(); return $this->db->inTransaction();
} }
/** /**
@@ -667,7 +649,7 @@ final class PDOAdapter implements iDB
*/ */
public function transactional(Closure $callback): mixed public function transactional(Closure $callback): mixed
{ {
if (true === $this->pdo->inTransaction()) { if (true === $this->db->inTransaction()) {
$this->viaTransaction = true; $this->viaTransaction = true;
$result = $callback($this); $result = $callback($this);
$this->viaTransaction = false; $this->viaTransaction = false;
@@ -675,19 +657,20 @@ final class PDOAdapter implements iDB
} }
try { try {
$this->pdo->beginTransaction(); $this->db->start();
$this->viaTransaction = true; $this->viaTransaction = true;
$result = $callback($this); $result = $callback($this);
$this->viaTransaction = false; $this->viaTransaction = false;
$this->pdo->commit(); $this->db->commit();
return $result; return $result;
} catch (PDOException $e) { } catch (PDOException $e) {
$this->pdo->rollBack(); $this->db->rollBack();
$this->viaTransaction = false;
throw $e; throw $e;
} finally {
$this->viaTransaction = false;
} }
} }
@@ -701,8 +684,8 @@ final class PDOAdapter implements iDB
*/ */
public function __destruct() public function __destruct()
{ {
if (true === $this->singleTransaction && true === $this->pdo->inTransaction()) { if (true === $this->singleTransaction && true === $this->db->inTransaction()) {
$this->pdo->commit(); $this->db->commit();
} }
$this->stmt = []; $this->stmt = [];
@@ -827,7 +810,7 @@ final class PDOAdapter implements iDB
$sql = "SELECT * FROM state WHERE " . iState::COLUMN_TYPE . " = :type {$sqlEpisode} {$sqlGuids} LIMIT 1"; $sql = "SELECT * FROM state WHERE " . iState::COLUMN_TYPE . " = :type {$sqlEpisode} {$sqlGuids} LIMIT 1";
$stmt = $this->pdo->prepare($sql); $stmt = $this->db->prepare($sql);
if (false === $this->execute($stmt, $cond)) { if (false === $this->execute($stmt, $cond)) {
throw new DBException( throw new DBException(
@@ -857,29 +840,7 @@ final class PDOAdapter implements iDB
*/ */
private function execute(PDOStatement $stmt, array $cond = []): bool private function execute(PDOStatement $stmt, array $cond = []): bool
{ {
for ($i = 0; $i <= self::LOCK_RETRY; $i++) { return $this->wrap(fn(PDOAdapter $adapter) => $stmt->execute($cond));
try {
return $stmt->execute($cond);
} catch (PDOException $e) {
if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) {
if ($i >= self::LOCK_RETRY) {
throw $e;
}
$sleep = self::LOCK_RETRY + random_int(1, 3);
$this->logger->warning("PDOAdapter: Database is locked. sleeping for '{sleep}s'.", [
'sleep' => $sleep
]);
sleep($sleep);
} else {
throw $e;
}
}
}
return false;
} }
/** /**
@@ -895,29 +856,7 @@ final class PDOAdapter implements iDB
*/ */
private function query(string $sql): PDOStatement|false private function query(string $sql): PDOStatement|false
{ {
for ($i = 0; $i <= self::LOCK_RETRY; $i++) { return $this->wrap(fn(PDOAdapter $adapter) => $adapter->db->query($sql));
try {
return $this->pdo->query($sql);
} catch (PDOException $e) {
if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) {
if ($i >= self::LOCK_RETRY) {
throw $e;
}
$sleep = self::LOCK_RETRY + random_int(1, 3);
$this->logger->warning("PDOAdapter: Database is locked. sleeping for '{sleep}s'.", [
'sleep' => $sleep,
]);
sleep($sleep);
} else {
throw $e;
}
}
}
return false;
} }
/** /**
@@ -981,4 +920,57 @@ final class PDOAdapter implements iDB
default => '"' . $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

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Libs\Database\PDO; namespace App\Libs\Database\PDO;
use App\Libs\Config; use App\Libs\Config;
use App\Libs\Database\DBLayer;
use App\Libs\Entity\StateInterface as iFace; use App\Libs\Entity\StateInterface as iFace;
use App\Libs\Guid; use App\Libs\Guid;
use PDO; use PDO;
@@ -41,10 +42,10 @@ final class PDODataMigration
/** /**
* Class constructor. * Class constructor.
* *
* @param PDO $pdo The PDO instance to use for database connection. * @param DBLayer $db The PDO instance to use for database connection.
* @param LoggerInterface $logger The logger instance to use for logging. * @param LoggerInterface $logger The logger instance to use for logging.
*/ */
public function __construct(private PDO $pdo, private LoggerInterface $logger) public function __construct(private DBLayer $db, private LoggerInterface $logger)
{ {
$this->version = Config::get('database.version'); $this->version = Config::get('database.version');
$this->dbPath = dirname(after(Config::get('database.dsn'), 'sqlite:')); $this->dbPath = dirname(after(Config::get('database.dsn'), 'sqlite:'));
@@ -125,15 +126,15 @@ final class PDODataMigration
] ]
); );
if (!$this->pdo->inTransaction()) { if (!$this->db->inTransaction()) {
$this->pdo->beginTransaction(); $this->db->start();
} }
$columns = implode(', ', iFace::ENTITY_KEYS); $columns = implode(', ', iFace::ENTITY_KEYS);
$binds = ':' . implode(', :', iFace::ENTITY_KEYS); $binds = ':' . implode(', :', iFace::ENTITY_KEYS);
/** @noinspection SqlInsertValues */ /** @noinspection SqlInsertValues */
$insert = $this->pdo->prepare("INSERT INTO state ({$columns}) VALUES({$binds})"); $insert = $this->db->prepare("INSERT INTO state ({$columns}) VALUES({$binds})");
$stmt = $oldDB->query("SELECT * FROM state"); $stmt = $oldDB->query("SELECT * FROM state");
@@ -230,8 +231,8 @@ final class PDODataMigration
]); ]);
} }
if ($this->pdo->inTransaction()) { if ($this->db->inTransaction()) {
$this->pdo->commit(); $this->db->commit();
} }
$stmt = null; $stmt = null;
@@ -290,15 +291,15 @@ final class PDODataMigration
PDO::SQLITE_ATTR_OPEN_FLAGS => PDO::SQLITE_OPEN_READONLY, PDO::SQLITE_ATTR_OPEN_FLAGS => PDO::SQLITE_OPEN_READONLY,
]); ]);
if (!$this->pdo->inTransaction()) { if (!$this->db->inTransaction()) {
$this->pdo->beginTransaction(); $this->db->start();
} }
$columns = implode(', ', iFace::ENTITY_KEYS); $columns = implode(', ', iFace::ENTITY_KEYS);
$binds = ':' . implode(', :', iFace::ENTITY_KEYS); $binds = ':' . implode(', :', iFace::ENTITY_KEYS);
/** @noinspection SqlInsertValues */ /** @noinspection SqlInsertValues */
$insert = $this->pdo->prepare("INSERT INTO state ({$columns}) VALUES({$binds})"); $insert = $this->db->prepare("INSERT INTO state ({$columns}) VALUES({$binds})");
foreach ($oldDB->query("SELECT * FROM state") as $row) { foreach ($oldDB->query("SELECT * FROM state") as $row) {
$row[iFace::COLUMN_EXTRA] = json_decode( $row[iFace::COLUMN_EXTRA] = json_decode(
@@ -419,8 +420,8 @@ final class PDODataMigration
$insert->execute($arr); $insert->execute($arr);
} }
if ($this->pdo->inTransaction()) { if ($this->db->inTransaction()) {
$this->pdo->commit(); $this->db->commit();
} }
$oldDB = null; $oldDB = null;

View File

@@ -5,10 +5,10 @@ declare(strict_types=1);
namespace App\Libs\Database\PDO; namespace App\Libs\Database\PDO;
use App\Libs\Config; use App\Libs\Config;
use App\Libs\Database\DBLayer;
use App\Libs\Entity\StateInterface as iState; use App\Libs\Entity\StateInterface as iState;
use App\Libs\Guid; use App\Libs\Guid;
use App\Libs\Options; use App\Libs\Options;
use PDO;
use Psr\Log\LoggerInterface as iLogger; use Psr\Log\LoggerInterface as iLogger;
/** /**
@@ -40,10 +40,10 @@ final class PDOIndexer
/** /**
* Class constructor. * Class constructor.
* *
* @param PDO $db The PDO object used for database connections and queries. * @param DBLayer $db The PDO object used for database connections and queries.
* @param iLogger $logger The logger object used for logging information. * @param iLogger $logger The logger object used for logging information.
*/ */
public function __construct(private PDO $db, private iLogger $logger) public function __construct(private DBLayer $db, private iLogger $logger)
{ {
} }
@@ -169,7 +169,7 @@ final class PDOIndexer
if (!$this->db->inTransaction()) { if (!$this->db->inTransaction()) {
$startedTransaction = true; $startedTransaction = true;
$this->db->beginTransaction(); $this->db->start();
} }
foreach ($queries as $query) { foreach ($queries as $query) {

View File

@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Libs\Database\PDO; namespace App\Libs\Database\PDO;
use App\Libs\Database\DatabaseInterface as iDB; use App\Libs\Database\DatabaseInterface as iDB;
use App\Libs\Database\DBLayer;
use App\Libs\Stream; use App\Libs\Stream;
use PDO;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use RuntimeException; use RuntimeException;
use SplFileObject; use SplFileObject;
@@ -36,12 +36,12 @@ final class PDOMigrations
/** /**
* Constructs a new instance of the class. * Constructs a new instance of the class.
* *
* @param PDO $pdo The database connection object. * @param DBLayer $db The database connection object.
* @param LoggerInterface $logger The logger instance. * @param LoggerInterface $logger The logger instance.
* *
* @return void * @return void
*/ */
public function __construct(private PDO $pdo, private LoggerInterface $logger) public function __construct(private DBLayer $db, private LoggerInterface $logger)
{ {
$this->path = __DIR__ . '/../../../../migrations'; $this->path = __DIR__ . '/../../../../migrations';
$this->driver = $this->getDriver(); $this->driver = $this->getDriver();
@@ -119,7 +119,7 @@ final class PDOMigrations
'name' => ag($migrate, 'name') 'name' => ag($migrate, 'name')
])); ]));
$this->pdo->exec((string)ag($migrate, iDB::MIGRATE_UP)); $this->db->exec((string)ag($migrate, iDB::MIGRATE_UP));
$this->setVersion(ag($migrate, 'id')); $this->setVersion(ag($migrate, 'id'));
} }
@@ -200,7 +200,7 @@ final class PDOMigrations
public function runMaintenance(): int|bool public function runMaintenance(): int|bool
{ {
if ('sqlite' === $this->driver) { if ('sqlite' === $this->driver) {
return $this->pdo->exec('VACUUM;'); return $this->db->exec('VACUUM;');
} }
return false; return false;
@@ -213,7 +213,7 @@ final class PDOMigrations
*/ */
private function getVersion(): int private function getVersion(): int
{ {
return (int)$this->pdo->query('PRAGMA user_version')->fetchColumn(); return (int)$this->db->query('PRAGMA user_version')->fetchColumn();
} }
/** /**
@@ -225,7 +225,7 @@ final class PDOMigrations
*/ */
private function setVersion(int $version): void private function setVersion(int $version): void
{ {
$this->pdo->exec('PRAGMA user_version = ' . $version); $this->db->exec('PRAGMA user_version = ' . $version);
} }
/** /**
@@ -235,9 +235,9 @@ final class PDOMigrations
*/ */
private function getDriver(): string private function getDriver(): string
{ {
$driver = $this->pdo->getAttribute($this->pdo::ATTR_DRIVER_NAME); $driver = $this->db->getDriver();
if (empty($driver) || !is_string($driver)) { if (empty($driver)) {
$driver = 'unknown'; $driver = 'unknown';
} }

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Libs\Exceptions;
interface AppExceptionInterface
{
/**
* Add extra context to the exception.
*
* @param string $key The context key
* @param mixed $val The context value
*
* @return AppExceptionInterface
*/
public function addContext(string $key, mixed $val): AppExceptionInterface;
/**
* Replace the context with the provided array.
*
* @param array $context The context array.
*
* @return AppExceptionInterface
*/
public function setContext(array $context): AppExceptionInterface;
/**
* Get the context array or a specific key from the context.
*
* @param string|null $key The context key.
*
* @return mixed
*/
public function getContext(string|null $key = null): mixed;
/**
* Does the exception contain context?
*
* @return bool
*/
public function hasContext(): bool;
}

View File

@@ -4,11 +4,14 @@ declare(strict_types=1);
namespace App\Libs\Exceptions\Backends; namespace App\Libs\Exceptions\Backends;
use App\Libs\Exceptions\AppExceptionInterface;
use App\Libs\Exceptions\UseAppException;
use ErrorException; use ErrorException;
/** /**
* Base Exception class for the backends errors. * Base Exception class for the backends errors.
*/ */
class BackendException extends ErrorException class BackendException extends ErrorException implements AppExceptionInterface
{ {
use UseAppException;
} }

View File

@@ -12,8 +12,10 @@ use RuntimeException;
* The DatabaseException class extends the RuntimeException class and represents an exception * The DatabaseException class extends the RuntimeException class and represents an exception
* that is thrown when there is an error related to the database operation. * that is thrown when there is an error related to the database operation.
*/ */
class DatabaseException extends RuntimeException class DBAdapterException extends RuntimeException implements AppExceptionInterface
{ {
use UseAppException;
public string $queryString = ''; public string $queryString = '';
public array $bind = []; public array $bind = [];
@@ -52,21 +54,21 @@ class DatabaseException extends RuntimeException
return $this->bind; return $this->bind;
} }
public function setFile(string $file): DatabaseException public function setFile(string $file): DBAdapterException
{ {
$this->file = $file; $this->file = $file;
return $this; return $this;
} }
public function setLine(int $line): DatabaseException public function setLine(int $line): DBAdapterException
{ {
$this->line = $line; $this->line = $line;
return $this; return $this;
} }
public function setOptions(array $options): DatabaseException public function setOptions(array $options): DBAdapterException
{ {
$this->options = $options; $this->options = $options;

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Libs\Exceptions;
use PDOException;
/**
* Class DatabaseException
*
* The DatabaseException class extends the RuntimeException class and represents an exception
* that is thrown when there is an error related to the database operation.
*/
class DBLayerException extends PDOException implements AppExceptionInterface
{
use UseAppException;
public string $queryString = '';
public array $bind = [];
public array $options = [];
public array|null $errorInfo = [];
/**
* @param string $queryString
* @param array $bind
* @param array $errorInfo
* @param string|int $errorCode
*
* @return $this
*/
public function setInfo(
string $queryString,
array $bind = [],
array $errorInfo = [],
mixed $errorCode = 0
): self {
$this->queryString = $queryString;
$this->bind = $bind;
$this->errorInfo = $errorInfo;
$this->code = $errorCode;
return $this;
}
public function getQueryString(): string
{
return $this->queryString;
}
public function getQueryBind(): array
{
return $this->bind;
}
public function setFile(string $file): DBLayerException
{
$this->file = $file;
return $this;
}
public function setLine(int $line): DBLayerException
{
$this->line = $line;
return $this;
}
public function setOptions(array $options): DBLayerException
{
$this->options = $options;
return $this;
}
}

View File

@@ -6,8 +6,10 @@ namespace App\Libs\Exceptions;
use RuntimeException; use RuntimeException;
class EmitterException extends RuntimeException class EmitterException extends RuntimeException implements AppExceptionInterface
{ {
use UseAppException;
public static function forHeadersSent(string $filename, int $line): self public static function forHeadersSent(string $filename, int $line): self
{ {
return new self(r('Unable to emit response. Headers already sent in %s:%d', [ return new self(r('Unable to emit response. Headers already sent in %s:%d', [

View File

@@ -7,6 +7,7 @@ namespace App\Libs\Exceptions;
/** /**
* Class ErrorException * Class ErrorException
*/ */
class ErrorException extends \ErrorException class ErrorException extends \ErrorException implements AppExceptionInterface
{ {
use UseAppException;
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Libs\Exceptions; namespace App\Libs\Exceptions;
class HttpException extends RuntimeException class HttpException extends RuntimeException implements AppExceptionInterface
{ {
use UseAppException;
} }

View File

@@ -7,6 +7,7 @@ namespace App\Libs\Exceptions;
/** /**
* Class InvalidArgumentException * Class InvalidArgumentException
*/ */
class InvalidArgumentException extends \InvalidArgumentException class InvalidArgumentException extends \InvalidArgumentException implements AppExceptionInterface
{ {
use UseAppException;
} }

View File

@@ -7,6 +7,7 @@ namespace App\Libs\Exceptions;
/** /**
* General runtime exception. * General runtime exception.
*/ */
class RuntimeException extends \RuntimeException class RuntimeException extends \RuntimeException implements AppExceptionInterface
{ {
use UseAppException;
} }

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Libs\Exceptions;
trait UseAppException
{
protected array $appContext = [];
public function addContext(string $key, mixed $value): AppExceptionInterface
{
$this->appContext = ag_set($this->appContext, $key, $value);
return $this;
}
public function setContext(array $context): AppExceptionInterface
{
$this->appContext = $context;
return $this;
}
public function getContext(string|null $key = null): mixed
{
if (null === $key) {
return $this->appContext;
}
return ag($this->appContext, $key);
}
public function hasContext(): bool
{
return !empty($this->appContext);
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Libs\Exceptions;
/** /**
* Class ValidationException * Class ValidationException
*/ */
class ValidationException extends RuntimeException class ValidationException extends RuntimeException implements AppExceptionInterface
{ {
use UseAppException;
} }

View File

@@ -6,7 +6,7 @@ namespace App\Libs;
use App\Libs\Exceptions\InvalidArgumentException; use App\Libs\Exceptions\InvalidArgumentException;
use JsonSerializable; use JsonSerializable;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface as iLogger;
use Stringable; use Stringable;
/** /**
@@ -91,9 +91,9 @@ final class Guid implements JsonSerializable, Stringable
*/ */
private array $data = []; private array $data = [];
/** /**
* @var null|LoggerInterface $logger The logger instance used for logging. * @var null|iLogger $logger The logger instance used for logging.
*/ */
private static LoggerInterface|null $logger = null; private static iLogger|null $logger = null;
/** /**
* Create list of db => external id list. * Create list of db => external id list.
@@ -114,7 +114,7 @@ final class Guid implements JsonSerializable, Stringable
if (false === is_string($key)) { if (false === is_string($key)) {
$this->getLogger()->info( $this->getLogger()->info(
'Ignoring [{backend}] {item.type} [{item.title}] external id. Unexpected key type [{given}] was given.', "Ignoring '{backend}' {item.type} '{item.title}' external id. Unexpected key type '{given}' was given.",
[ [
'key' => (string)$key, 'key' => (string)$key,
'given' => get_debug_type($key), 'given' => get_debug_type($key),
@@ -126,7 +126,7 @@ final class Guid implements JsonSerializable, Stringable
if (null === (self::SUPPORTED[$key] ?? null)) { if (null === (self::SUPPORTED[$key] ?? null)) {
$this->getLogger()->info( $this->getLogger()->info(
'Ignoring [{backend}] {item.type} [{item.title}] [{key}] external id. Not supported.', "Ignoring '{backend}' {item.type} '{item.title}' '{key}' external id. Not supported.",
[ [
'key' => $key, 'key' => $key,
...$context, ...$context,
@@ -137,7 +137,7 @@ final class Guid implements JsonSerializable, Stringable
if (self::SUPPORTED[$key] !== ($valueType = get_debug_type($value))) { if (self::SUPPORTED[$key] !== ($valueType = get_debug_type($value))) {
$this->getLogger()->info( $this->getLogger()->info(
'Ignoring [{backend}] {item.type} [{item.title}] [{key}] external id. Unexpected value type.', "Ignoring '{backend}' {item.type} '{item.title}' '{key}' external id. Unexpected value type.",
[ [
'key' => $key, 'key' => $key,
'condition' => [ 'condition' => [
@@ -153,7 +153,7 @@ final class Guid implements JsonSerializable, Stringable
if (null !== (self::VALIDATE_GUID[$key] ?? null)) { if (null !== (self::VALIDATE_GUID[$key] ?? null)) {
if (1 !== preg_match(self::VALIDATE_GUID[$key]['pattern'], $value)) { if (1 !== preg_match(self::VALIDATE_GUID[$key]['pattern'], $value)) {
$this->getLogger()->info( $this->getLogger()->info(
'Ignoring [{backend}] {item.type} [{item.title}] [{key}] external id. Unexpected [{given}] value, expecting [{expected}].', "Ignoring '{backend}' {item.type} '{item.title}' '{key}' external id. Unexpected value '{given}'. Expecting '{expected}'.",
[ [
'key' => $key, 'key' => $key,
'expected' => self::VALIDATE_GUID[$key]['example'], 'expected' => self::VALIDATE_GUID[$key]['example'],
@@ -172,9 +172,9 @@ final class Guid implements JsonSerializable, Stringable
/** /**
* Set the logger instance for the class. * Set the logger instance for the class.
* *
* @param LoggerInterface $logger The logger instance to be set. * @param iLogger $logger The logger instance to be set.
*/ */
public static function setLogger(LoggerInterface $logger): void public static function setLogger(iLogger $logger): void
{ {
self::$logger = $logger; self::$logger = $logger;
} }
@@ -229,12 +229,10 @@ final class Guid implements JsonSerializable, Stringable
$lookup = 'guid_' . $db; $lookup = 'guid_' . $db;
if (false === array_key_exists($lookup, self::SUPPORTED)) { if (false === array_key_exists($lookup, self::SUPPORTED)) {
throw new InvalidArgumentException( throw new InvalidArgumentException(r("Invalid db '{db}' source was given. Expecting '{db_list}'.", [
r('Invalid db [{db}] source was given. Expecting [{db_list}].', [
'db' => $db, 'db' => $db,
'db_list' => implode(', ', array_map(fn($f) => after($f, 'guid_'), array_keys(self::SUPPORTED))), 'db_list' => implode(', ', array_map(fn($f) => after($f, 'guid_'), array_keys(self::SUPPORTED))),
]) ]));
);
} }
if (null === (self::VALIDATE_GUID[$lookup] ?? null)) { if (null === (self::VALIDATE_GUID[$lookup] ?? null)) {
@@ -242,13 +240,11 @@ final class Guid implements JsonSerializable, Stringable
} }
if (1 !== @preg_match(self::VALIDATE_GUID[$lookup]['pattern'], $id)) { if (1 !== @preg_match(self::VALIDATE_GUID[$lookup]['pattern'], $id)) {
throw new InvalidArgumentException( throw new InvalidArgumentException(r("Invalid value '{value}' for '{db}' GUID. Expecting '{example}'.", [
r('Invalid [{value}] value for [{db}]. Expecting [{example}].', [
'db' => $db, 'db' => $db,
'value' => $id, 'value' => $id,
'example' => self::VALIDATE_GUID[$lookup]['example'], 'example' => self::VALIDATE_GUID[$lookup]['example'],
]) ]));
);
} }
return true; return true;
@@ -283,12 +279,12 @@ final class Guid implements JsonSerializable, Stringable
/** /**
* Get instance of logger. * Get instance of logger.
* *
* @return LoggerInterface * @return iLogger
*/ */
private function getLogger(): LoggerInterface private function getLogger(): iLogger
{ {
if (null === self::$logger) { if (null === self::$logger) {
self::$logger = Container::get(LoggerInterface::class); self::$logger = Container::get(iLogger::class);
} }
return self::$logger; return self::$logger;

View File

@@ -120,7 +120,7 @@ final class DirectMapper implements iImport
$this->addPointers($entity, $pointer); $this->addPointers($entity, $pointer);
} }
$this->logger->info("MAPPER: Preloaded '{pointers}' pointers into memory.", [ $this->logger->info("DirectMapper: Preloaded '{pointers}' pointers into memory.", [
'mapper' => afterLast(self::class, '\\'), 'mapper' => afterLast(self::class, '\\'),
'pointers' => number_format(count($this->pointers)), 'pointers' => number_format(count($this->pointers)),
]); ]);
@@ -147,7 +147,7 @@ final class DirectMapper implements iImport
Message::increment("{$entity->via}.{$entity->type}.failed"); Message::increment("{$entity->via}.{$entity->type}.failed");
$this->logger->notice( $this->logger->notice(
"MAPPER: Ignoring '{backend}' '{title}'. Does not exist in database. And backend set as metadata source only.", "DirectMapper: Ignoring '{backend}: {title}'. Does not exist in database. And backend set as metadata source only.",
[ [
'metaOnly' => true, 'metaOnly' => true,
'backend' => $entity->via, 'backend' => $entity->via,
@@ -191,7 +191,7 @@ final class DirectMapper implements iImport
} }
} }
$this->logger->notice("MAPPER: '{backend}' added '{title}' as new item.", [ $this->logger->notice("DirectMapper: '{backend}' added '{title}' as new item.", [
'backend' => $entity->via, 'backend' => $entity->via,
'title' => $entity->getName(), 'title' => $entity->getName(),
true === $this->inTraceMode() ? 'trace' : 'metadata' => $data, true === $this->inTraceMode() ? 'trace' : 'metadata' => $data,
@@ -209,7 +209,8 @@ final class DirectMapper implements iImport
$this->actions[$entity->type]['failed']++; $this->actions[$entity->type]['failed']++;
Message::increment("{$entity->via}.{$entity->type}.failed"); Message::increment("{$entity->via}.{$entity->type}.failed");
$this->logger->error( $this->logger->error(
message: "MAPPER: Exception '{error.kind}' was thrown unhandled in adding '{backend}' '{title}'. '{error.message}' at '{error.file}:{error.line}'.", ...lw(
message: "DirectMapper: Exception '{error.kind}' was thrown unhandled in adding '{backend}: {title}'. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'error' => [ 'error' => [
'kind' => $e::class, 'kind' => $e::class,
@@ -220,7 +221,9 @@ final class DirectMapper implements iImport
'backend' => $entity->via, 'backend' => $entity->via,
'title' => $entity->getName(), 'title' => $entity->getName(),
'state' => $entity->getAll() 'state' => $entity->getAll()
] ],
e: $e
)
); );
} }
@@ -248,7 +251,7 @@ final class DirectMapper implements iImport
$this->removePointers($local)->addPointers($local, $local->id); $this->removePointers($local)->addPointers($local, $local->id);
$this->logger->notice("MAPPER: '{backend}' updated '{title}' metadata.", [ $this->logger->notice("DirectMapper: '{backend}' updated '{title}' metadata.", [
'id' => $local->id, 'id' => $local->id,
'backend' => $entity->via, 'backend' => $entity->via,
'title' => $local->getName(), 'title' => $local->getName(),
@@ -269,7 +272,8 @@ final class DirectMapper implements iImport
$this->actions[$local->type]['failed']++; $this->actions[$local->type]['failed']++;
Message::increment("{$entity->via}.{$local->type}.failed"); Message::increment("{$entity->via}.{$local->type}.failed");
$this->logger->error( $this->logger->error(
message: "MAPPER: Exception '{error.kind}' was thrown unhandled in '{backend}' '{title}' handle tainted. '{error.message}' at '{error.file}:{error.line}'.", ...lw(
message: "DirectMapper: Exception '{error.kind}' was thrown unhandled in '{backend}: {title}' handle tainted. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'error' => [ 'error' => [
'kind' => $e::class, 'kind' => $e::class,
@@ -284,7 +288,9 @@ final class DirectMapper implements iImport
'database' => $local->getAll(), 'database' => $local->getAll(),
'backend' => $entity->getAll() 'backend' => $entity->getAll()
], ],
] ],
e: $e
)
); );
} }
@@ -306,7 +312,7 @@ final class DirectMapper implements iImport
} }
$this->logger->notice( $this->logger->notice(
"MAPPER: '{backend}' item '{id}: {title}' is marked as '{state}' vs local state '{local_state}', However due to the following reason '{reasons}' it was not considered as valid state.", "DirectMapper: '{backend}' item '{id}: {title}' is marked as '{state}' vs local state '{local_state}', However due to the following reason '{reasons}' it was not considered as valid state.",
[ [
'id' => $local->id, 'id' => $local->id,
'backend' => $entity->via, 'backend' => $entity->via,
@@ -321,7 +327,7 @@ final class DirectMapper implements iImport
} }
if ($this->inTraceMode()) { if ($this->inTraceMode()) {
$this->logger->info("MAPPER: '{backend}' '{title}' No metadata changes detected.", [ $this->logger->info("DirectMapper: '{backend}: {title}' No metadata changes detected.", [
'id' => $local->id, 'id' => $local->id,
'backend' => $entity->via, 'backend' => $entity->via,
'title' => $local->getName(), 'title' => $local->getName(),
@@ -364,7 +370,7 @@ final class DirectMapper implements iImport
} }
} }
$this->logger->notice("MAPPER: '{backend}' marked '{title}' as 'unplayed'.", [ $this->logger->notice("DirectMapper: '{backend}' marked '{title}' as 'unplayed'.", [
'id' => $cloned->id, 'id' => $cloned->id,
'backend' => $entity->via, 'backend' => $entity->via,
'title' => $cloned->getName(), 'title' => $cloned->getName(),
@@ -381,7 +387,8 @@ final class DirectMapper implements iImport
$this->actions[$local->type]['failed']++; $this->actions[$local->type]['failed']++;
Message::increment("{$entity->via}.{$local->type}.failed"); Message::increment("{$entity->via}.{$local->type}.failed");
$this->logger->error( $this->logger->error(
message: "MAPPER: Exception '{error.kind}' was thrown unhandled in '{backend}' '{title}' handle old entity unplayed. '{error.message}' at '{error.file}:{error.line}'.", ...lw(
message: "DirectMapper: Exception '{error.kind}' was thrown unhandled in '{backend}: {title}' handle old entity unplayed. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'error' => [ 'error' => [
'kind' => $e::class, 'kind' => $e::class,
@@ -396,7 +403,9 @@ final class DirectMapper implements iImport
'database' => $cloned->getAll(), 'database' => $cloned->getAll(),
'backend' => $entity->getAll() 'backend' => $entity->getAll()
], ],
] ],
e: $e
)
); );
} }
@@ -428,7 +437,7 @@ final class DirectMapper implements iImport
} }
$local = $local->apply($entity, fields: $_keys); $local = $local->apply($entity, fields: $_keys);
$this->logger->notice( $this->logger->notice(
$progress ? "MAPPER: '{backend}' updated '{title}' due to play progress change." : "MAPPER: '{backend}' updated '{title}' metadata.", $progress ? "DirectMapper: '{backend}' updated '{title}' due to play progress change." : "DirectMapper: '{backend}' updated '{title}' metadata.",
[ [
'id' => $cloned->id, 'id' => $cloned->id,
'backend' => $entity->via, 'backend' => $entity->via,
@@ -463,7 +472,8 @@ final class DirectMapper implements iImport
$this->actions[$local->type]['failed']++; $this->actions[$local->type]['failed']++;
Message::increment("{$entity->via}.{$local->type}.failed"); Message::increment("{$entity->via}.{$local->type}.failed");
$this->logger->error( $this->logger->error(
message: "MAPPER: Exception '{error.kind}' was thrown unhandled in '{backend}' '{title}' handle old entity always update metadata. '{error.message}' at '{error.file}:{error.line}'.", ...lw(
message: "DirectMapper: Exception '{error.kind}' was thrown unhandled in '{backend}: {title}' handle old entity always update metadata. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'error' => [ 'error' => [
'kind' => $e::class, 'kind' => $e::class,
@@ -478,7 +488,9 @@ final class DirectMapper implements iImport
'database' => $cloned->getAll(), 'database' => $cloned->getAll(),
'backend' => $entity->getAll() 'backend' => $entity->getAll()
], ],
] ],
e: $e
)
); );
} }
@@ -490,7 +502,7 @@ final class DirectMapper implements iImport
if ($entity->isWatched() !== $local->isWatched()) { if ($entity->isWatched() !== $local->isWatched()) {
$this->logger->notice( $this->logger->notice(
"MAPPER: '{backend}' item '{id}: {title}' is marked as '{state}' vs local state '{local_state}', However due to the remote item date '{remote_date}' being older than the last backend sync date '{local_date}'. it was not considered as valid state.", "DirectMapper: '{backend}' item '{id}: {title}' is marked as '{state}' vs local state '{local_state}', However due to the remote item date '{remote_date}' being older than the last backend sync date '{local_date}'. it was not considered as valid state.",
[ [
'id' => $cloned->id, 'id' => $cloned->id,
'backend' => $entity->via, 'backend' => $entity->via,
@@ -505,7 +517,7 @@ final class DirectMapper implements iImport
} }
if ($this->inTraceMode()) { if ($this->inTraceMode()) {
$this->logger->debug("MAPPER: Ignoring '{backend}' '{title}'. No changes detected.", [ $this->logger->debug("DirectMapper: Ignoring '{backend}: {title}'. No changes detected.", [
'id' => $cloned->id, 'id' => $cloned->id,
'backend' => $entity->via, 'backend' => $entity->via,
'title' => $cloned->getName(), 'title' => $cloned->getName(),
@@ -521,7 +533,7 @@ final class DirectMapper implements iImport
public function add(iState $entity, array $opts = []): self public function add(iState $entity, array $opts = []): self
{ {
if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) { if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) {
$this->logger->warning("MAPPER: Ignoring '{backend}' '{title}'. No valid/supported external ids.", [ $this->logger->warning("DirectMapper: Ignoring '{backend}: {title}'. No valid/supported external ids.", [
'id' => $entity->id, 'id' => $entity->id,
'backend' => $entity->via, 'backend' => $entity->via,
'title' => $entity->getName(), 'title' => $entity->getName(),
@@ -532,7 +544,7 @@ final class DirectMapper implements iImport
if (true === $entity->isEpisode() && $entity->episode < 1) { if (true === $entity->isEpisode() && $entity->episode < 1) {
$this->logger->warning( $this->logger->warning(
"MAPPER: Ignoring '{backend}' '{id}: {title}'. Item was marked as episode but no episode number was provided.", "DirectMapper: Ignoring '{backend}' '{id}: {title}'. Item was marked as episode but no episode number was provided.",
[ [
'id' => $entity->id ?? ag($entity->getMetadata($entity->via), iState::COLUMN_ID, ''), 'id' => $entity->id ?? ag($entity->getMetadata($entity->via), iState::COLUMN_ID, ''),
'backend' => $entity->via, 'backend' => $entity->via,
@@ -584,7 +596,7 @@ final class DirectMapper implements iImport
* 3 - mark entity as tainted and re-process it. * 3 - mark entity as tainted and re-process it.
*/ */
if (true === $hasAfter && true === $cloned->isWatched() && false === $entity->isWatched()) { if (true === $hasAfter && true === $cloned->isWatched() && false === $entity->isWatched()) {
$message = "MAPPER: Watch state conflict detected in '{backend}: {title}' '{new_state}' vs local state '{id}: {current_state}'."; $message = "DirectMapper: Watch state conflict detected in '{backend}: {title}' '{new_state}' vs local state '{id}: {current_state}'.";
$hasMeta = count($cloned->getMetadata($entity->via)) >= 1; $hasMeta = count($cloned->getMetadata($entity->via)) >= 1;
$hasDate = $entity->updated === ag($cloned->getMetadata($entity->via), iState::COLUMN_META_DATA_PLAYED_AT); $hasDate = $entity->updated === ag($cloned->getMetadata($entity->via), iState::COLUMN_META_DATA_PLAYED_AT);
@@ -630,10 +642,10 @@ final class DirectMapper implements iImport
$changes = $local->diff(fields: $keys); $changes = $local->diff(fields: $keys);
$message = "MAPPER: '{backend}' Updated '{title}'."; $message = "DirectMapper: '{backend}' Updated '{title}'.";
if ($cloned->isWatched() !== $local->isWatched()) { if ($cloned->isWatched() !== $local->isWatched()) {
$message = "MAPPER: '{backend}' Updated and marked '{id}: {title}' as '{state}'."; $message = "DirectMapper: '{backend}' Updated and marked '{id}: {title}' as '{state}'.";
if (null !== $onStateUpdate) { if (null !== $onStateUpdate) {
$onStateUpdate($local); $onStateUpdate($local);
@@ -664,7 +676,8 @@ final class DirectMapper implements iImport
$this->actions[$local->type]['failed']++; $this->actions[$local->type]['failed']++;
Message::increment("{$entity->via}.{$local->type}.failed"); Message::increment("{$entity->via}.{$local->type}.failed");
$this->logger->error( $this->logger->error(
message: "MAPPER: Exception '{error.kind}' was thrown unhandled in '{backend}' '{title}' add. '{error.message}' at '{error.file}:{error.line}'.", ...lw(
message: "DirectMapper: Exception '{error.kind}' was thrown unhandled in '{backend}: {title}' add. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
'error' => [ 'error' => [
'kind' => $e::class, 'kind' => $e::class,
@@ -680,7 +693,9 @@ final class DirectMapper implements iImport
'backend' => $entity->getAll() 'backend' => $entity->getAll()
], ],
'trace' => $e->getTrace(), 'trace' => $e->getTrace(),
] ],
e: $e
)
); );
} }
@@ -700,7 +715,10 @@ final class DirectMapper implements iImport
]; ];
} }
$this->logger->debug("MAPPER: Ignoring '{backend}' '{title}'. Metadata & play state are identical.", $context); $this->logger->debug(
"DirectMapper: Ignoring '{backend}: {title}'. Metadata & play state are identical.",
$context
);
Message::increment("{$entity->via}.{$entity->type}.ignored_no_change"); Message::increment("{$entity->via}.{$entity->type}.ignored_no_change");

View File

@@ -604,7 +604,24 @@ final class MemoryMapper implements iImport
} }
} catch (PDOException $e) { } catch (PDOException $e) {
$list[$entity->type]['failed']++; $list[$entity->type]['failed']++;
$this->logger->error($e->getMessage(), $entity->getAll()); $this->logger->error(
...lw(
message: "MemoryMapper: Exception '{error.kind}' was thrown unhandled in {mode} '{backend}: {title}'. '{error.message}' at '{error.file}:{error.line}'.",
context: [
'error' => [
'kind' => $e::class,
'line' => $e->getLine(),
'message' => $e->getMessage(),
'file' => after($e->getFile(), ROOT_PATH),
],
'state' => $entity->getAll(),
'backend' => $entity->via,
'title' => $entity->getName(),
'mode' => $entity->id === null ? 'add' : 'update',
],
e: $e
)
);
} }
} }

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Libs; namespace App\Libs;
use Closure;
use Monolog\Handler\TestHandler; use Monolog\Handler\TestHandler;
use Throwable;
class TestCase extends \PHPUnit\Framework\TestCase class TestCase extends \PHPUnit\Framework\TestCase
{ {
@@ -25,4 +27,46 @@ class TestCase extends \PHPUnit\Framework\TestCase
fwrite(STDOUT, $logs['formatted'] . PHP_EOL); fwrite(STDOUT, $logs['formatted'] . PHP_EOL);
} }
} }
/**
* Checks if the given closure throws an exception.
*
* @param Closure $closure
* @param string $reason
* @param Throwable|string $exception Expected exception class
* @param string $exceptionMessage (optional) Exception message
* @param int|null $exceptionCode (optional) Exception code
* @return void
*/
protected function checkException(
Closure $closure,
string $reason,
Throwable|string $exception,
string $exceptionMessage = '',
int|null $exceptionCode = null,
): void {
$caught = null;
try {
$closure();
} catch (Throwable $e) {
$caught = $e;
} finally {
if (null === $caught) {
$this->fail($reason);
} else {
$this->assertInstanceOf(
is_object($exception) ? $exception::class : $exception,
$caught,
$reason
);
if (!empty($exceptionMessage)) {
$this->assertStringContainsString($exceptionMessage, $caught->getMessage(), $reason);
}
if (!empty($exceptionCode)) {
$this->assertEquals($exceptionCode, $caught->getCode(), $reason);
}
}
}
}
} }

View File

@@ -12,10 +12,13 @@ use App\Libs\Attributes\Scanner\Item as ScannerItem;
use App\Libs\Config; use App\Libs\Config;
use App\Libs\ConfigFile; use App\Libs\ConfigFile;
use App\Libs\Container; use App\Libs\Container;
use App\Libs\Database\DBLayer;
use App\Libs\DataUtil; use App\Libs\DataUtil;
use App\Libs\Entity\StateInterface as iState; use App\Libs\Entity\StateInterface as iState;
use App\Libs\Enums\Http\Status; use App\Libs\Enums\Http\Status;
use App\Libs\Events\DataEvent; use App\Libs\Events\DataEvent;
use App\Libs\Exceptions\AppExceptionInterface;
use App\Libs\Exceptions\DBLayerException;
use App\Libs\Exceptions\InvalidArgumentException; use App\Libs\Exceptions\InvalidArgumentException;
use App\Libs\Exceptions\RuntimeException; use App\Libs\Exceptions\RuntimeException;
use App\Libs\Extends\Date; use App\Libs\Extends\Date;
@@ -256,9 +259,8 @@ if (!function_exists('ag_delete')) {
} }
if (is_int($path)) { if (is_int($path)) {
if (isset($array[$path])) { // -- if the path is int, and it's exists, it should have been caught by
unset($array[$path]); // -- the first if condition. So, we can safely return the array as is.
}
return $array; return $array;
} }
@@ -966,21 +968,13 @@ if (false === function_exists('r_array')) {
$pattern = '#' . preg_quote($tagLeft, '#') . '([\w_.]+)' . preg_quote($tagRight, '#') . '#is'; $pattern = '#' . preg_quote($tagLeft, '#') . '([\w_.]+)' . preg_quote($tagRight, '#') . '#is';
$status = preg_match_all($pattern, $text, $matches); preg_match_all($pattern, $text, $matches);
if (false === $status || $status < 1) {
return ['message' => $text, 'context' => $context];
}
$replacements = []; $replacements = [];
foreach ($matches[1] as $key) { foreach ($matches[1] as $key) {
$placeholder = $tagLeft . $key . $tagRight; $placeholder = $tagLeft . $key . $tagRight;
if (false === str_contains($text, $placeholder)) {
continue;
}
if (false === ag_exists($context, $key)) { if (false === ag_exists($context, $key)) {
continue; continue;
} }
@@ -1025,7 +1019,7 @@ if (false === function_exists('generateRoutes')) {
* *
* @return array The generated routes. * @return array The generated routes.
*/ */
function generateRoutes(string $type = 'cli'): array function generateRoutes(string $type = 'cli', array $opts = []): array
{ {
$dirs = [__DIR__ . '/../Commands']; $dirs = [__DIR__ . '/../Commands'];
foreach (array_keys(Config::get('supported', [])) as $backend) { foreach (array_keys(Config::get('supported', [])) as $backend) {
@@ -1040,7 +1034,7 @@ if (false === function_exists('generateRoutes')) {
$routes_cli = (new Router($dirs))->generate(); $routes_cli = (new Router($dirs))->generate();
$cache = Container::get(iCache::class); $cache = $opts[iCache::class] ?? Container::get(iCache::class);
try { try {
$cache->set('routes_cli', $routes_cli, new DateInterval('PT1H')); $cache->set('routes_cli', $routes_cli, new DateInterval('PT1H'));
@@ -1144,7 +1138,7 @@ if (false === function_exists('getSystemMemoryInfo')) {
* *
* @return array{ MemTotal: float, MemFree: float, MemAvailable: float, SwapTotal: float, SwapFree: float } * @return array{ MemTotal: float, MemFree: float, MemAvailable: float, SwapTotal: float, SwapFree: float }
*/ */
function getSystemMemoryInfo(): array function getSystemMemoryInfo(string $memFile = '/proc/meminfo'): array
{ {
$keys = [ $keys = [
'MemTotal' => 'mem_total', 'MemTotal' => 'mem_total',
@@ -1156,11 +1150,11 @@ if (false === function_exists('getSystemMemoryInfo')) {
$result = []; $result = [];
if (!is_readable('/proc/meminfo')) { if (!is_readable($memFile)) {
return $result; return $result;
} }
if (false === ($lines = @file('/proc/meminfo', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES))) { if (false === ($lines = @file($memFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES))) {
return $result; return $result;
} }
@@ -1212,6 +1206,10 @@ if (!function_exists('checkIgnoreRule')) {
{ {
$urlParts = parse_url($guid); $urlParts = parse_url($guid);
if (false === is_array($urlParts)) {
throw new RuntimeException('Invalid ignore rule was given.');
}
if (null === ($db = ag($urlParts, 'user'))) { if (null === ($db = ag($urlParts, 'user'))) {
throw new RuntimeException('No db source was given.'); throw new RuntimeException('No db source was given.');
} }
@@ -1541,10 +1539,6 @@ if (!function_exists('parseEnvFile')) {
} }
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (empty($line)) {
continue;
}
if (true === str_starts_with($line, '#') || false === str_contains($line, '=')) { if (true === str_starts_with($line, '#') || false === str_contains($line, '=')) {
continue; continue;
} }
@@ -1613,11 +1607,12 @@ if (!function_exists('isTaskWorkerRunning')) {
/** /**
* Check if the task worker is running. This function is only available when running in a container. * Check if the task worker is running. This function is only available when running in a container.
* *
* @param string $pidFile (Optional) The PID file to check.
* @param bool $ignoreContainer (Optional) Whether to ignore the container check. * @param bool $ignoreContainer (Optional) Whether to ignore the container check.
* *
* @return array{ status: bool, message: string } * @return array{ status: bool, message: string }
*/ */
function isTaskWorkerRunning(bool $ignoreContainer = false): array function isTaskWorkerRunning(string $pidFile = '/tmp/ws-job-runner.pid', bool $ignoreContainer = false): array
{ {
if (false === $ignoreContainer && !inContainer()) { if (false === $ignoreContainer && !inContainer()) {
return [ return [
@@ -1635,8 +1630,6 @@ if (!function_exists('isTaskWorkerRunning')) {
]; ];
} }
$pidFile = '/tmp/ws-job-runner.pid';
if (!file_exists($pidFile)) { if (!file_exists($pidFile)) {
return [ return [
'status' => false, 'status' => false,
@@ -1651,7 +1644,33 @@ if (!function_exists('isTaskWorkerRunning')) {
return ['status' => false, 'message' => $e->getMessage()]; return ['status' => false, 'message' => $e->getMessage()];
} }
if (file_exists(r('/proc/{pid}/status', ['pid' => $pid]))) { switch (PHP_OS) {
case 'Linux':
{
$status = file_exists(r('/proc/{pid}/status', ['pid' => $pid]));
}
break;
case 'WINNT':
{
// -- Windows does not have a /proc directory so we need different way to get the status.
@exec("tasklist /FI \"PID eq {$pid}\" 2>NUL", $output);
// -- windows doesn't return 0 if the process is not found. we need to parse the output.
$status = false;
foreach ($output as $line) {
if (false === str_contains($line, $pid)) {
continue;
}
$status = true;
break;
}
}
break;
default:
$status = false;
break;
}
if (true === $status) {
return ['status' => true, 'restartable' => true, 'message' => 'Task worker is running.']; return ['status' => true, 'restartable' => true, 'message' => 'Task worker is running.'];
} }
@@ -1865,6 +1884,7 @@ if (!function_exists('cacheableItem')) {
* @param Closure $function * @param Closure $function
* @param DateInterval|int|null $ttl * @param DateInterval|int|null $ttl
* @param bool $ignoreCache * @param bool $ignoreCache
* @param array $opts
* *
* @return mixed * @return mixed
*/ */
@@ -1872,15 +1892,16 @@ if (!function_exists('cacheableItem')) {
string $key, string $key,
Closure $function, Closure $function,
DateInterval|int|null $ttl = null, DateInterval|int|null $ttl = null,
bool $ignoreCache = false bool $ignoreCache = false,
array $opts = [],
): mixed { ): mixed {
$cache = Container::get(iCache::class); $cache = $opts[iCache::class] ?? Container::get(iCache::class);
if (!$ignoreCache && $cache->has($key)) { if (!$ignoreCache && $cache->has($key)) {
return $cache->get($key); return $cache->get($key);
} }
$reflectContainer = Container::get(ReflectionContainer::class); $reflectContainer = $opts[ReflectionContainer::class] ?? Container::get(ReflectionContainer::class);
$item = $reflectContainer->call($function); $item = $reflectContainer->call($function);
if (null === $ttl) { if (null === $ttl) {
@@ -2050,7 +2071,73 @@ if (!function_exists('getBackend')) {
$default = $configFile->get($name); $default = $configFile->get($name);
$default['name'] = $name; $default['name'] = $name;
$data = array_replace_recursive($default, $config);
return makeBackend(array_replace_recursive($default, $config), $name); return makeBackend($data, $name);
}
}
if (!function_exists('lw')) {
/**
* log wrapper.
*
* The use case for this wrapper is to enhance the log context with db exception information.
* All logs should be wrapped with this function. it will probably be enhanced to include further context.
* in the future.
*
* @param string $message The log message.
* @param array $context The log context.
* @param Throwable|null $e The exception.
*
* @return array{ message: string, context: array} The wrapped log message and context.
*/
function lw(string $message, array $context, Throwable|null $e = null): array
{
if (null === $e) {
return [
'message' => $message,
'context' => $context,
];
}
if (true === ($e instanceof DBLayerException)) {
$context[DBLayer::class] = [
'query' => $e->getQueryString(),
'bind' => $e->getQueryBind(),
'error' => $e->errorInfo ?? [],
];
}
if (true === ($e instanceof AppExceptionInterface) && $e->hasContext()) {
$context[AppExceptionInterface::class] = $e->getContext();
}
return [
'message' => $message,
'context' => $context,
];
}
}
if (!function_exists('timeIt')) {
/**
* Time the execution of a function.
*
* @param Closure $function The function to time.
* @param string $name The name of the function.
* @param int $round (Optional) The number of decimal places to round to.
*
* @return string
*/
function timeIt(Closure $function, string $name, int $round = 6): string
{
$start = microtime(true);
$function();
$end = microtime(true);
return r("Execution time is '{time}' for '{name}'", [
'name' => $name,
'time' => round($end - $start, $round),
]);
} }
} }

View File

@@ -118,12 +118,12 @@ class GetLibrariesListTest extends TestCase
$this->assertFalse($response->status); $this->assertFalse($response->status);
$this->assertNotNull($response->error); $this->assertNotNull($response->error);
$this->assertSame( $this->assertStringContainsString(
'ERROR: Request for [Plex] libraries returned with unexpected [401] status code.', "ERROR: Request for 'Plex' libraries returned with unexpected '401' status code.",
(string)$response->error (string)$response->error
); );
$this->assertNull($response->response); $this->assertNull($response->response);
$this->assertFalse($response->error->hasException()); $this->assertTrue($response->error->hasException());
} }
} }

View File

@@ -6,10 +6,11 @@ declare(strict_types=1);
namespace Tests\Database; namespace Tests\Database;
use App\Libs\Database\DatabaseInterface as iDB; use App\Libs\Database\DatabaseInterface as iDB;
use App\Libs\Database\DBLayer;
use App\Libs\Database\PDO\PDOAdapter; use App\Libs\Database\PDO\PDOAdapter;
use App\Libs\Entity\StateEntity; use App\Libs\Entity\StateEntity;
use App\Libs\Entity\StateInterface; use App\Libs\Entity\StateInterface;
use App\Libs\Exceptions\DatabaseException as DBException; use App\Libs\Exceptions\DBAdapterException as DBException;
use App\Libs\Guid; use App\Libs\Guid;
use App\Libs\TestCase; use App\Libs\TestCase;
use DateTimeImmutable; use DateTimeImmutable;
@@ -46,7 +47,7 @@ class PDOAdapterTest extends TestCase
$logger->pushHandler($this->handler); $logger->pushHandler($this->handler);
Guid::setLogger($logger); Guid::setLogger($logger);
$this->db = new PDOAdapter($logger, new PDO('sqlite::memory:')); $this->db = new PDOAdapter($logger, new DBLayer(new PDO('sqlite::memory:')));
$this->db->migrations('up'); $this->db->migrations('up');
} }

View File

View File

View File

@@ -0,0 +1 @@
0

View File

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,55 @@
MemTotal: 131598708 kB
MemFree: 10636272 kB
MemAvailable: 113059644 kB
Buffers: 1851320 kB
Cached: 96874768 kB
SwapCached: 199724 kB
Active: 15252072 kB
Inactive: 96757496 kB
Active(anon): 4046452 kB
Inactive(anon): 11604056 kB
Active(file): 11205620 kB
Inactive(file): 85153440 kB
Unevictable: 43880 kB
Mlocked: 0 kB
SwapTotal: 144758584 kB
SwapFree: 140512824 kB
Zswap: 0 kB
Zswapped: 0 kB
Dirty: 28 kB
Writeback: 0 kB
AnonPages: 12869660 kB
Mapped: 760892 kB
Shmem: 2367028 kB
KReclaimable: 7315620 kB
Slab: 8107296 kB
SReclaimable: 7315620 kB
SUnreclaim: 791676 kB
KernelStack: 42512 kB
PageTables: 78184 kB
SecPageTables: 0 kB
NFS_Unstable: 0 kB
Bounce: 0 kB
WritebackTmp: 0 kB
CommitLimit: 210557936 kB
Committed_AS: 38290840 kB
VmallocTotal: 34359738367 kB
VmallocUsed: 198508 kB
VmallocChunk: 0 kB
Percpu: 37760 kB
HardwareCorrupted: 0 kB
AnonHugePages: 5797888 kB
ShmemHugePages: 47104 kB
ShmemPmdMapped: 0 kB
FileHugePages: 0 kB
FilePmdMapped: 0 kB
HugePages_Total: 0
HugePages_Free: 0
HugePages_Rsvd: 0
HugePages_Surp: 0
Hugepagesize: 2048 kB
Hugetlb: 0 kB
DirectMap4k: 11644304 kB
DirectMap2M: 119123968 kB
DirectMap1G: 3145728 kB

View File

@@ -0,0 +1,62 @@
test_plex:
type: plex
url: 'https://plex.example.invalid'
token: t000000000000000000p
user: 11111111
uuid: s00000000000000000000000000000000000000p
export:
enabled: true
lastSync: null
import:
enabled: true
lastSync: 1724173445
webhook:
match:
user: true
uuid: true
options:
ignore: '22,1,2,3'
LIBRARY_SEGMENT: 1000
ADMIN_TOKEN: plex_admin_token
use_old_progress_endpoint: true
plex_user_uuid: r00000000000000p
test_jellyfin:
type: jellyfin
url: 'https://jellyfin.example.invalid'
token: t000000000000000000000000000000j
user: u000000000000000000000000000000j
uuid: s000000000000000000000000000000j
export:
enabled: true
lastSync: null
import:
enabled: true
lastSync: 1724173445
webhook:
match:
user: false
uuid: true
options:
ignore: 'i000000000000000000000000000000j,i100000000000000000000000000000j'
MAX_EPISODE_RANGE: 6
test_emby:
type: emby
url: 'https://emby.example.invalid'
token: t000000000000000000000000000000e
user: u000000000000000000000000000000e
uuid: s000000000000000000000000000000e
import:
enabled: true
lastSync: 1724173445
export:
enabled: true
lastSync: null
webhook:
match:
user: false
uuid: true
options:
MAX_EPISODE_RANGE: 6
IMPORT_METADATA_ONLY: true

View File

@@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
namespace Tests\Libs;
use App\Libs\ConfigFile;
use App\Libs\Extends\LogMessageProcessor;
use App\Libs\TestCase;
use InvalidArgumentException;
use Monolog\Handler\TestHandler;
use Monolog\Logger;
use Throwable;
class ConfigFileTest extends TestCase
{
private array $data = ['foo' => 'bar', 'baz' => 'kaz', 'sub' => ['key' => 'val']];
private array $params = [];
private Logger|null $logger = null;
protected function setUp(): void
{
parent::setUp();
$this->handler = new TestHandler();
$this->logger = new Logger('logger', processors: [new LogMessageProcessor()]);
$this->logger->pushHandler($this->handler);
$this->params = [
'file' => __DIR__ . '/../Fixtures/test_servers.yaml',
'type' => 'yaml',
'autoSave' => false,
'autoCreate' => false,
'autoBackup' => false,
'opts' => [
'json_decode' => JSON_UNESCAPED_UNICODE,
'json_encode' => JSON_UNESCAPED_UNICODE,
],
];
}
public function test_constructor()
{
$this->checkException(
closure: fn() => new ConfigFile(
'nonexistent.json',
'json',
autoSave: false,
autoCreate: false,
autoBackup: false
),
reason: 'If file does not exist, and autoCreate is set to false, an exception should be thrown.',
exception: InvalidArgumentException::class,
exceptionMessage: "File 'nonexistent.json' does not exist.",
);
$this->checkException(
closure: fn() => new ConfigFile(
'nonexistent.json',
'php',
autoSave: false,
autoCreate: false,
autoBackup: false
),
reason: 'If type is not supported, an exception should be thrown.',
exception: InvalidArgumentException::class,
exceptionMessage: "Invalid content type 'php'.",
);
$this->checkException(
closure: fn() => new ConfigFile(
'/root/test.json',
'json',
autoSave: false,
autoCreate: true,
autoBackup: false
),
reason: 'If file is not writable, an exception should be thrown.',
exception: InvalidArgumentException::class,
exceptionMessage: "could not be created",
);
try {
$class = new ConfigFile(...$this->params);
$this->assertInstanceOf(ConfigFile::class, $class);
} catch (Throwable $e) {
$this->fail('If all conditions are met, NO exception should be been thrown. ' . $e->getMessage());
}
try {
$class = ConfigFile::open(...$this->params);
$this->assertInstanceOf(ConfigFile::class, $class);
} catch (Throwable $e) {
$this->fail('If all conditions are met, NO exception should be been thrown. ' . $e->getMessage());
}
}
public function test_setLogger()
{
$tmpFile = tempnam(sys_get_temp_dir(), 'test');
copy(__DIR__ . '/../Fixtures/test_servers.yaml', $tmpFile);
$params = $this->params;
$params['file'] = $tmpFile;
$params['autoBackup'] = true;
$class = new ConfigFile(...$params);
try {
$class->setLogger($this->logger);
$class->set('foo', 'bar');
$class->delete('kaz');
// -- trigger external change
ConfigFile::open(...$params)->set('bar', 'kaz')->persist();
// -- should trigger warning.
$class->persist();
$this->assertStringContainsString(
'has been modified since last load.',
$this->handler->getRecords()[0]['message']
);
} catch (Throwable $e) {
$this->fail('If correct logger is passed, no exception should be thrown. ' . $e->getMessage());
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
if (file_exists($tmpFile . '.bak')) {
unlink($tmpFile . '.bak');
}
}
}
public function test_delete()
{
$tmpFile = tempnam(sys_get_temp_dir(), 'test');
copy(__DIR__ . '/../Fixtures/test_servers.yaml', $tmpFile);
$params = $this->params;
$params['file'] = $tmpFile;
try {
$this->assertArrayNotHasKey(
'test_jellyfin',
ConfigFile::open(...$params)->delete('test_jellyfin')->getAll(),
'->delete: Failed to delete key from YAML file.'
);
$class = ConfigFile::open(...$params);
unset($class['test_jellyfin']);
$this->assertArrayNotHasKey(
'test_jellyfin',
$class->getAll(),
'ArrayAccess: Failed to delete key from YAML file.'
);
} catch (Throwable $e) {
$this->fail('If correct logger is passed, no exception should be thrown. ' . $e->getMessage());
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
}
public function test_get()
{
$class = new ConfigFile(...$this->params);
$this->assertArrayHasKey(
'token',
$class->get('test_plex', []),
'->get: Invalid response from parsing YAML file.'
);
$this->assertArrayHasKey('token', $class['test_plex'], 'ArrayAccess: Invalid response from parsing YAML file.');
$this->assertArrayNotHasKey(
'token',
$class->get('test_not_set', []),
'Invalid response from parsing YAML file.'
);
$this->assertNull($class['test_not_set'], 'ArrayAccess: Must return null if key does not exist.');
}
public function test_has()
{
$class = new ConfigFile(...$this->params);
$this->assertTrue($class->has('test_plex'), '->has: Must return true if key exists.');
$this->assertTrue(isset($class['test_plex']), 'ArrayAccess: Must return true if key exists.');
$this->assertFalse($class->has('test_not_set'), '->has: Must return false if key does not exist.');
$this->assertFalse(isset($class['test_not_set']), 'ArrayAccess: Must return false if key does not exist.');
}
public function test_set()
{
$tmpFile = tempnam(sys_get_temp_dir(), 'test');
copy(__DIR__ . '/../Fixtures/test_servers.yaml', $tmpFile);
$params = $this->params;
$params['file'] = $tmpFile;
try {
$this->assertArrayHasKey(
'test_foo',
ConfigFile::open(...$params)->set('test_foo', $this->data)->getAll(),
'->set: Failed to set key in YAML file.'
);
$class = ConfigFile::open(...$params);
$class['test_foo'] = $this->data;
$this->assertArrayHasKey('test_foo', $class->getAll(), 'ArrayAccess: Failed to set key.');
// -- test deep array.
$class->set('test_plex.options.foo', 'bar');
$this->assertArrayHasKey('foo', $class->get('test_plex.options', []), 'Failed to set deep key.');
$class['foo'] = ['bar' => ['jaz' => ['kaz' => 'baz']]];
$this->assertArrayHasKey('foo', $class->getAll(), 'ArrayAccess: Failed to set key.');
$class->set('foo', ['bar' => ['tax' => 'max']]);
$class->override()->persist();
$class = ConfigFile::open(...$params);
$this->assertArrayHasKey('test_foo', $class->getAll(), 'failed to persist changes.');
$this->assertArrayHasKey('foo', $class->getAll(), 'ArrayAccess: failed to persist changes.');
$this->assertArrayHasKey('bar', $class->get('foo', []), 'failed to persist changes.');
} catch (Throwable $e) {
$this->fail('If correct logger is passed, no exception should be thrown. ' . $e->getMessage());
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
}
public function test_configFile_set()
{
$params['file'] = tempnam(sys_get_temp_dir(), 'test');
try {
try {
copy(__DIR__ . '/../Fixtures/test_servers.yaml', $params['file']);
$class = new ConfigFile(...$params);
$this->assertInstanceOf(ConfigFile::class, $class);
} catch (Throwable $e) {
$this->fail('If file exists and is readable, no exception should be thrown. ' . $e->getMessage());
}
$class = ConfigFile::open(...$params);
$this->assertInstanceOf(ConfigFile::class, $class);
$this->assertArrayHasKey('token', $class->get('test_plex', []), 'Invalid response from parsing YAML file.');
$this->assertArrayNotHasKey(
'token',
$class->get('test_not_set', []),
'Invalid response from parsing YAML file.'
);
$this->assertTrue($class->has('test_plex'), 'Must return true if key exists.');
$this->assertFalse($class->has('test_not_set'), 'Must return false if key does not exist.');
$this->assertArrayHasKey('token', $class['test_plex'], 'Failed to get arrayAccess key correctly.');
$this->assertTrue(isset($class['test_plex']), 'Must return true if arrayAccess key exists.');
$this->assertNull($class['test_not_set'], 'Must return null if arrayAccess key does not exist.');
$this->assertFalse(isset($class['test_not_set']), 'Must return false if arrayAccess key does not exist.');
} finally {
if (file_exists($params['file'])) {
unlink($params['file']);
}
}
}
}

View File

@@ -4,23 +4,108 @@ declare(strict_types=1);
namespace Tests\Libs; namespace Tests\Libs;
use App\Backends\Plex\PlexClient;
use App\Libs\Config; use App\Libs\Config;
use App\Libs\Container;
use App\Libs\Database\DBLayer;
use App\Libs\Entity\StateEntity; use App\Libs\Entity\StateEntity;
use App\Libs\Enums\Http\Method;
use App\Libs\Enums\Http\Status; use App\Libs\Enums\Http\Status;
use App\Libs\Exceptions\AppExceptionInterface;
use App\Libs\Exceptions\DBLayerException;
use App\Libs\Exceptions\InvalidArgumentException; use App\Libs\Exceptions\InvalidArgumentException;
use App\Libs\Exceptions\RuntimeException; use App\Libs\Exceptions\RuntimeException;
use App\Libs\Extends\ReflectionContainer;
use App\Libs\TestCase; use App\Libs\TestCase;
use JsonMachine\Items; use JsonMachine\Items;
use JsonMachine\JsonDecoder\ErrorWrappingDecoder; use JsonMachine\JsonDecoder\ErrorWrappingDecoder;
use JsonMachine\JsonDecoder\ExtJsonDecoder; use JsonMachine\JsonDecoder\ExtJsonDecoder;
use JsonSerializable;
use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\Stream; use Nyholm\Psr7\Stream;
use Nyholm\Psr7Server\ServerRequestCreator; use Nyholm\Psr7Server\ServerRequestCreator;
use Psr\SimpleCache\CacheInterface;
use Stringable;
use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\Yaml\Yaml;
use TypeError;
class HelpersTest extends TestCase class HelpersTest extends TestCase
{ {
protected CacheInterface|null $cache = null;
protected function setUp(): void
{
parent::setUp();
$this->cache = new class implements CacheInterface {
public array $cache = [];
public bool $throw = false;
public function get(string $key, mixed $default = null): mixed
{
return $this->cache[$key] ?? $default;
}
public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool
{
if ($this->throw) {
throw new class() extends \InvalidArgumentException implements
\Psr\SimpleCache\InvalidArgumentException {
};
}
$this->cache[$key] = $value;
return true;
}
public function delete(string $key): bool
{
unset($this->cache[$key]);
return true;
}
public function clear(): bool
{
$this->cache = [];
return true;
}
public function getMultiple(iterable $keys, mixed $default = null): iterable
{
foreach ($keys as $key) {
yield $key => $this->get($key, $default);
}
}
public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool
{
foreach ($values as $key => $value) {
$this->set($key, $value, $ttl);
}
return true;
}
public function deleteMultiple(iterable $keys): bool
{
foreach ($keys as $key) {
$this->delete($key);
}
return true;
}
public function has(string $key): bool
{
return isset($this->cache[$key]);
}
public function reset(): void
{
$this->cache = [];
}
};
}
public function test_env_conditions(): void public function test_env_conditions(): void
{ {
$values = [ $values = [
@@ -171,6 +256,32 @@ class HelpersTest extends TestCase
ag_set([], 'foo.kaz', 'taz'), ag_set([], 'foo.kaz', 'taz'),
'When a nested key is passed, it will be saved in format of [key => [nested_key => value]]' 'When a nested key is passed, it will be saved in format of [key => [nested_key => value]]'
); );
$exception = null;
try {
ag_set(['foo' => 'bar'], 'foo.bar.taz', 'baz');
} catch (\Throwable $e) {
$exception = $e;
} finally {
$this->assertSame(
TypeError::class,
$exception ? $exception::class : null,
'When trying to set value to non-array, exception is thrown.'
);
}
$exception = null;
try {
ag_set(['foo' => ['bar' => ['taz' => 'tt']]], 'foo.bar.taz.tt', 'baz');
} catch (\Throwable $e) {
$exception = $e;
} finally {
$this->assertSame(
RuntimeException::class,
$exception ? $exception::class : null,
'When trying to set value to existing key, exception is thrown.'
);
}
} }
public function test_ag_exits(): void public function test_ag_exits(): void
@@ -221,6 +332,23 @@ class HelpersTest extends TestCase
ag_delete($arr, 'foo'), ag_delete($arr, 'foo'),
'When simple key is passed, and it exists, it is deleted, and copy of the modified array is returned' 'When simple key is passed, and it exists, it is deleted, and copy of the modified array is returned'
); );
$this->assertSame(
[0 => 'foo', 1 => 'bar'],
ag_delete([0 => 'foo', 1 => 'bar', 2 => 'taz'], 2),
'When an int key is passed, and it exists, it is deleted, and copy of the modified array is returned'
);
$this->assertSame(
$arr,
ag_delete($arr, 121),
'When an int key is passed, and it does not exist, original array is returned'
);
$this->assertSame(
$arr,
ag_delete($arr, 'test.bar'),
'When a non-existing key is passed, original array is returned.'
);
} }
public function test_fixPath(): void public function test_fixPath(): void
@@ -332,6 +460,7 @@ class HelpersTest extends TestCase
] ]
]); ]);
$data = ['foo' => 'bar']; $data = ['foo' => 'bar'];
api_response(200, $data);
$response = api_response(Status::OK, $data); $response = api_response(Status::OK, $data);
$this->assertSame(Status::OK->value, $response->getStatusCode()); $this->assertSame(Status::OK->value, $response->getStatusCode());
$this->assertSame('application/json', $response->getHeaderLine('Content-Type')); $this->assertSame('application/json', $response->getHeaderLine('Content-Type'));
@@ -354,11 +483,50 @@ class HelpersTest extends TestCase
]); ]);
$data = ['error' => ['code' => Status::BAD_REQUEST->value, 'message' => 'error message']]; $data = ['error' => ['code' => Status::BAD_REQUEST->value, 'message' => 'error message']];
$response = api_error('error message', Status::BAD_REQUEST); $response = api_error('error message', Status::BAD_REQUEST, headers: [
'X-Test-Header' => 'test',
]);
$this->assertSame(Status::BAD_REQUEST->value, $response->getStatusCode()); $this->assertSame(Status::BAD_REQUEST->value, $response->getStatusCode());
$this->assertSame('application/json', $response->getHeaderLine('Content-Type')); $this->assertSame('application/json', $response->getHeaderLine('Content-Type'));
$this->assertSame(getAppVersion(), $response->getHeaderLine('X-Application-Version')); $this->assertSame(getAppVersion(), $response->getHeaderLine('X-Application-Version'));
$this->assertSame('test', $response->getHeaderLine('X-Test-Header'));
$this->assertSame($data, json_decode($response->getBody()->getContents(), true)); $this->assertSame($data, json_decode($response->getBody()->getContents(), true));
$response = api_error('error message', Status::BAD_REQUEST, opts: [
'callback' => fn($response) => $response->withStatus(Status::INTERNAL_SERVER_ERROR->value)
]);
$this->assertSame(Status::INTERNAL_SERVER_ERROR->value, $response->getStatusCode());
}
public function test_api_message(): void
{
Config::append([
'api' => [
'response' => [
'headers' => [
'Content-Type' => 'application/json',
'X-Application-Version' => fn() => getAppVersion(),
'Access-Control-Allow-Origin' => '*',
],
],
]
]);
$data = ['info' => ['code' => Status::OK->value, 'message' => 'info message']];
$response = api_message('info message', Status::OK, headers: [
'X-Test-Header' => 'test',
]);
$this->assertSame(Status::OK->value, $response->getStatusCode());
$this->assertSame('application/json', $response->getHeaderLine('Content-Type'));
$this->assertSame(getAppVersion(), $response->getHeaderLine('X-Application-Version'));
$this->assertSame('test', $response->getHeaderLine('X-Test-Header'));
$this->assertSame($data, json_decode($response->getBody()->getContents(), true));
$response = api_message('info message', Status::OK, opts: [
'callback' => fn($response) => $response->withStatus(Status::INTERNAL_SERVER_ERROR->value)
]);
$this->assertSame(Status::INTERNAL_SERVER_ERROR->value, $response->getStatusCode());
} }
public function test_httpClientChunks(): void public function test_httpClientChunks(): void
@@ -461,6 +629,51 @@ class HelpersTest extends TestCase
arrayToString($data, '@ '), arrayToString($data, '@ '),
'When array is passed, it is converted into array text separated by delimiter.' 'When array is passed, it is converted into array text separated by delimiter.'
); );
$cl = new class implements JsonSerializable {
public function jsonSerialize(): array
{
return ['foo' => 'bar'];
}
public function __toString(): string
{
return json_encode($this->jsonSerialize());
}
};
$cl2 = new class implements Stringable {
public function __toString(): string
{
return json_encode(['foo' => 'bar']);
}
};
$cl3 = new class() {
public string $foo = 'bar';
};
$this->assertSame(
'(baz: {"foo":"bar"})',
arrayToString(['baz' => $cl]),
'When array contains a class that implements JsonSerializable it is converted into array string.'
);
$this->assertSame(
'(baz: {"foo":"bar"}), (foo: true), (bar: false)',
arrayToString([
'baz' => $cl2,
'foo' => true,
'bar' => false,
]),
"When an object that implements Stringable is passed, it's casted to string"
);
$this->assertSame(
'(baz: [ (foo: bar) ])',
arrayToString(['baz' => $cl3]),
"When a class doesn't implement JsonSerializable or Stringable, it's converted to array. using object vars."
);
} }
public function test_isValidName(): void public function test_isValidName(): void
@@ -563,6 +776,9 @@ class HelpersTest extends TestCase
isIgnoredId('home_plex', 'movie', 'guid_tvdb', '1201', '121'), isIgnoredId('home_plex', 'movie', 'guid_tvdb', '1201', '121'),
'When ignore url is passed with and ignore list does not contain the url, false is returned.' 'When ignore url is passed with and ignore list does not contain the url, false is returned.'
); );
$this->expectException(InvalidArgumentException::class);
isIgnoredId('home_plex', 'not_real_type', 'guid_tvdb', '1200', '121');
} }
public function test_r(): void public function test_r(): void
@@ -597,6 +813,32 @@ class HelpersTest extends TestCase
'When array is passed, it is converted into array and placeholders are replaced with values.' 'When array is passed, it is converted into array and placeholders are replaced with values.'
); );
$message = 'foo bar,taz';
$context = ['obj' => ['foo' => 'bar', 'baz' => 'taz']];
$this->assertSame(
['message' => $message, 'context' => $context],
r_array($message, $context),
'When non-existing placeholder is passed, string is returned as it is.'
);
$this->assertSame(
'Time is: 2020-01-01T00:00:00+00:00',
r('Time is: {date}', ['date' => makeDate('2020-01-01', 'UTC')]),
'When date is passed, it is converted into string and placeholders are replaced with values.'
);
$this->assertSame(
'HTTP Status: 200',
r('HTTP Status: {status}', ['status' => Status::OK]),
'When Int backed Enum is passed, it is converted into its value and the placeholder is replaced with it.'
);
$this->assertSame(
'HTTP Method: POST',
r('HTTP Method: {method}', ['method' => Method::POST]),
'When String backed Enum is passed, it is converted into its value and the placeholder is replaced with it.'
);
$res = fopen('php://memory', 'r'); $res = fopen('php://memory', 'r');
$this->assertSame( $this->assertSame(
'foo [resource]', 'foo [resource]',
@@ -759,4 +1001,630 @@ class HelpersTest extends TestCase
$this->fail('This function shouldn\'t throw exception when invalid file is given.'); $this->fail('This function shouldn\'t throw exception when invalid file is given.');
} }
} }
public function test_generateRoutes()
{
$routes = generateRoutes('cli', [CacheInterface::class => $this->cache]);
$this->assertCount(
2,
$this->cache->cache,
'It should have generated two cache buckets for http and cli routes.'
);
$this->assertGreaterThanOrEqual(
1,
count($this->cache->cache['routes_cli']),
'It should have more than 1 route for cli routes.'
);
$this->assertGreaterThanOrEqual(
1,
count($this->cache->cache['routes_http']),
'It should have more than 1 route for cli routes.'
);
$this->assertSame(
$routes,
$this->cache->cache['routes_cli'],
'It should return cli routes when called with cli type.'
);
$this->cache->reset();
$this->assertSame(
generateRoutes('http', [CacheInterface::class => $this->cache]),
$this->cache->cache['routes_http'],
'It should return http routes. when called with http type.'
);
$this->cache->reset();
$this->cache->throw = true;
$routes = generateRoutes('http', [CacheInterface::class => $this->cache]);
$this->assertCount(0, $this->cache->cache, 'When cache throws exception, it should not save anything.');
$this->assertNotSame([], $routes, 'Routes should be generated even if cache throws exception.');
// --
$save = Config::get('supported', []);
Config::save('supported', ['not_set' => 'not_set_client', 'plex' => PlexClient::class,]);
$routes = generateRoutes('http', [CacheInterface::class => $this->cache]);
Config::save('supported', $save);
}
public function test_getSystemMemoryInfo()
{
/** @noinspection PhpUnhandledExceptionInspection */
$none = getSystemMemoryInfo(bin2hex(random_bytes(32)));
$this->assertIsArray($none, 'It should return array.');
$this->assertSame([], $none, 'When mem-file is not readable, it should return empty array.');
$info = getSystemMemoryInfo(__DIR__ . '/../Fixtures/meminfo_data.txt');
$this->assertIsArray($info, 'It should return array of memory info.');
$this->assertArrayHasKey('mem_total', $info, 'It should have total memory key.');
$this->assertArrayHasKey('mem_free', $info, 'It should have free memory key.');
$this->assertArrayHasKey('mem_available', $info, 'It should have available memory key.');
$this->assertArrayHasKey('swap_total', $info, 'It should have swap total key.');
$this->assertArrayHasKey('swap_free', $info, 'It should have swap free key.');
$keysValues = [
"mem_total" => 131598708000.0,
"mem_free" => 10636272000.0,
"mem_available" => 113059644000.0,
"swap_total" => 144758584000.0,
"swap_free" => 140512824000.0,
];
foreach ($keysValues as $key => $value) {
$this->assertSame($value, $info[$key], "It should have correct value for {$key} key.");
}
if (is_writeable(sys_get_temp_dir())) {
try {
$fileName = tempnam(sys_get_temp_dir(), 'meminfo');
$none = getSystemMemoryInfo($fileName);
$this->assertIsArray($none, 'It should return array.');
$this->assertSame([], $none, 'When mem-file is empty it should return empty array.');
} finally {
if (file_exists($fileName)) {
unlink($fileName);
}
}
} else {
$this->markTestSkipped('Temp directory is not writable.');
}
}
public function test_checkIgnoreRule()
{
Config::save('servers', ['test_backend' => []]);
$rule = 'movie://tvdb:276923@test_backend?id=133367';
$this->assertTrue(checkIgnoreRule($rule));
// -- if no db source is given, it should throw exception.
$exception = null;
try {
checkIgnoreRule('movie://test_backend?id=133367&garbage=1');
} catch (\Throwable $e) {
$exception = $e;
} finally {
$this->assertSame(RuntimeException::class, $exception ? $exception::class : null);
$this->assertSame(
'No db source was given.',
$exception?->getMessage(),
'When no db source is given, it should throw exception.'
);
}
$exception = null;
try {
checkIgnoreRule('movie://foo@test_backend?id=133367&garbage=1');
} catch (\Throwable $e) {
$exception = $e;
} finally {
$this->assertSame(RuntimeException::class, $exception ? $exception::class : null);
$this->assertStringContainsString(
"Invalid db source name 'foo' was given.",
$exception?->getMessage(),
'When invalid db source is given, it should throw exception.'
);
}
$exception = null;
try {
checkIgnoreRule('movie://tvdb@test_backend?id=133367&garbage=1');
} catch (\Throwable $e) {
$exception = $e;
} finally {
$this->assertSame(RuntimeException::class, $exception ? $exception::class : null);
$this->assertSame(
'No external id was given.',
$exception?->getMessage(),
'When no external id is given in the password part of url, it should throw exception.'
);
}
$exception = null;
try {
checkIgnoreRule('http://tvdb:123@test_backend?id=133367&garbage=1');
} catch (\Throwable $e) {
$exception = $e;
} finally {
$this->assertSame(
RuntimeException::class,
$exception ? $exception::class : null,
$exception?->getMessage() ?? ''
);
$this->assertStringContainsString(
"Invalid type 'http' was given.",
$exception?->getMessage(),
'When invalid type is given, it should throw exception.'
);
}
$exception = null;
try {
checkIgnoreRule('movie://tvdb:123@not_set?id=133367&garbage=1');
} catch (\Throwable $e) {
$exception = $e;
} finally {
$this->assertSame(
RuntimeException::class,
$exception ? $exception::class : null,
$exception?->getMessage() ?? ''
);
$this->assertStringContainsString(
"Invalid backend name 'not_set' was given.",
$exception?->getMessage(),
'When invalid backend name is given, it should throw exception.'
);
}
$exception = null;
try {
checkIgnoreRule('//tvdb:123@not_set?id=133367&garbage=1');
} catch (\Throwable $e) {
$exception = $e;
} finally {
$this->assertSame(
RuntimeException::class,
$exception ? $exception::class : null,
$exception?->getMessage() ?? ''
);
$this->assertStringContainsString(
'No type was given.',
$exception?->getMessage(),
'When no type is given, it should throw exception.'
);
}
$exception = null;
try {
checkIgnoreRule('//');
} catch (\Throwable $e) {
$exception = $e;
} finally {
$this->assertSame(
RuntimeException::class,
$exception ? $exception::class : null,
$exception?->getMessage() ?? ''
);
$this->assertStringContainsString(
'Invalid ignore rule was given.',
$exception?->getMessage(),
'When parse_url fails to parse url, it should throw exception.'
);
}
}
public function test_addCors()
{
$response = api_response(Status::OK, headers: ['X-Request-Id' => '1']);
$response = addCors($response, headers: [
'X-Test-Add' => 'test',
'X-Request-Id' => '2',
], methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']);
$this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
$this->assertSame(
'GET, POST, PUT, DELETE, PATCH, OPTIONS',
$response->getHeaderLine('Access-Control-Allow-Methods')
);
$this->assertSame(
'X-Application-Version, X-Request-Id, *',
$response->getHeaderLine('Access-Control-Allow-Headers')
);
$this->assertGreaterThanOrEqual(600, (int)$response->getHeaderLine('Access-Control-Max-Age'));
$this->assertSame('test', $response->getHeaderLine('X-Test-Add'));
$this->assertSame('1', $response->getHeaderLine('X-Request-Id'), 'The original header should not be altered.');
$this->assertNotSame(
'2',
$response->getHeaderLine('X-Request-Id'),
'AddCors: headers should not alter already set headers.'
);
}
public function test_deepArrayMerge()
{
$array1 = [
'foo' => 'bar',
'baz' => 'taz',
'kaz' => [
'raz' => 'maz',
'naz' => 'laz',
],
];
$array2 = [
'foo' => 'baz',
'kaz' => [
'raz' => 'baz',
'naz' => 'baz',
],
];
$expected = [
'foo' => 'baz',
'baz' => 'taz',
'kaz' => [
'raz' => 'baz',
'naz' => 'baz',
],
];
$this->assertSame($expected, deepArrayMerge([$array1, $array2]), 'It should merge arrays correctly.');
$this->assertSame(
[['foo' => 'baz'], ['baz' => 'taz'],],
deepArrayMerge([[['foo' => 'bar']], [['foo' => 'baz'], ['baz' => 'taz'],]], true),
'if preserve keys is true'
);
$this->assertSame(
[['foo' => 'bar'], ['foo' => 'baz'], ['baz' => 'taz'],],
deepArrayMerge([[['foo' => 'bar']], [['foo' => 'baz'], ['baz' => 'taz'],]], false),
'if preserve keys is false'
);
}
public function test_tryCatch()
{
$f = null;
$x = tryCatch(fn() => throw new RuntimeException(), fn($e) => $e, function () use (&$f) {
$f = 'finally_was_called';
});
$this->assertInstanceOf(
RuntimeException::class,
$x,
'When try block is successful, it should return the value.'
);
$this->assertSame('finally_was_called', $f, 'finally block should be executed.');
}
public function test_getServerColumnSpec()
{
$this->assertSame(
[
'key' => 'user',
'type' => 'string',
'visible' => true,
'description' => 'The user ID of the backend.',
],
getServerColumnSpec('user'),
'It should return correct column spec.'
);
$this->assertSame([], getServerColumnSpec('not_set'), 'It should return empty array when column is not set.');
}
public function test_getEnvSpec()
{
$this->assertSame(
[
'key' => 'WS_DATA_PATH',
'description' => 'Where to store main data. (config, db).',
'type' => 'string',
],
getEnvSpec('WS_DATA_PATH'),
'It should return correct env spec.'
);
$this->assertSame([], getEnvSpec('not_set'), 'It should return empty array when env is not set.');
}
public function test_isTaskWorkerRunning()
{
$_ENV['IN_CONTAINER'] = false;
$d = isTaskWorkerRunning();
$this->assertTrue($d['status'], 'When not in container, and $ignoreContainer is false, it should return true.');
unset($_ENV['IN_CONTAINER']);
$_ENV['DISABLE_CRON'] = true;
$d = isTaskWorkerRunning(ignoreContainer: true);
$this->assertFalse($d['status'], 'When DISABLE_CRON is set, it should return false.');
unset($_ENV['DISABLE_CRON']);
$d = isTaskWorkerRunning(pidFile: __DIR__ . '/../Fixtures/worker.pid', ignoreContainer: true);
$this->assertFalse($d['status'], 'When pid file is not found, it should return false.');
$tmpFile = tempnam(sys_get_temp_dir(), 'worker');
try {
file_put_contents($tmpFile, getmypid());
$d = isTaskWorkerRunning(pidFile: $tmpFile, ignoreContainer: true);
$this->assertTrue($d['status'], 'When pid file is found, and process exists it should return true.');
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
$tmpFile = tempnam(sys_get_temp_dir(), 'worker');
try {
/** @noinspection PhpUnhandledExceptionInspection */
file_put_contents($tmpFile, random_int(1, 9999) . getmypid());
$d = isTaskWorkerRunning(pidFile: $tmpFile, ignoreContainer: true);
$this->assertFalse(
$d['status'],
'When pid file is found, and process does not exists it should return false.'
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
}
public function test_findSideCarFiles()
{
$n = new \SplFileInfo(__DIR__ . '/../Fixtures/local_data/test.mkv');
$this->assertCount(
4,
findSideCarFiles($n),
'It should return side car files for given file.'
);
}
public function test_array_change_key_case_recursive()
{
$array = [
'foo' => 'bar',
'baz' => 'taz',
'kaz' => [
'raz' => 'maz',
'naz' => 'laz',
],
];
$expected = [
'FOO' => 'bar',
'BAZ' => 'taz',
'KAZ' => [
'RAZ' => 'maz',
'NAZ' => 'laz',
],
];
$this->assertSame(
$expected,
array_change_key_case_recursive($array, CASE_UPPER),
'It should change keys case.'
);
$this->assertSame(
$array,
array_change_key_case_recursive($expected, CASE_LOWER),
'It should change keys case.'
);
$this->expectException(RuntimeException::class);
array_change_key_case_recursive($array, 999);
}
public function test_getMimeType()
{
$this->assertSame(
'application/json',
getMimeType(__DIR__ . '/../Fixtures/plex_data.json'),
'It should return correct mime type.'
);
}
public function test_getExtension()
{
$this->assertSame(
'json',
getExtension(__DIR__ . '/../Fixtures/plex_data.json'),
'It should return correct extension.'
);
}
public function test_generateUUID()
{
#1ef6d04c-23c3-6442-9fd5-c87f54c3d8d1
$this->assertMatchesRegularExpression(
'/^[0-9a-f]{8}-[0-9a-f]{4}-6[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/',
generateUUID(),
'It should match valid UUID6 pattern.'
);
$this->assertMatchesRegularExpression(
'/^test\-[0-9a-f]{8}-[0-9a-f]{4}-6[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/',
generateUUID('test'),
'It should match valid UUID6 pattern.'
);
}
public function test_cacheableItem()
{
$reflectContainer = new class() {
public function call(callable $callable, array $args = []): mixed
{
return $callable(...$args);
}
};
$item = fn() => cacheableItem(
key: 'test',
function: fn() => 'foo',
ignoreCache: false,
opts: [
CacheInterface::class => $this->cache,
ReflectionContainer::class => $reflectContainer,
]
);
$this->assertSame('foo', $item(), 'It should return correct value.');
$this->assertSame('foo', $item(), 'It should return correct value.');
}
public function test_getPagination()
{
$factory = new Psr17Factory();
$creator = new ServerRequestCreator($factory, $factory, $factory, $factory);
$request = $creator->fromArrays([
'REQUEST_METHOD' => 'GET',
'QUERY_STRING' => 'page=2&perpage=10'
], get: ['page' => 2, 'perpage' => 10]);
[$page, $perpage, $start] = getPagination($request, 1);
$this->assertSame(2, $page, 'It should return correct page number.');
$this->assertSame(10, $perpage, 'It should return correct perpage number.');
$this->assertSame(10, $start, 'It should return correct start number.');
}
public function test_getBackend()
{
Container::init();
Config::init(require __DIR__ . '/../../config/config.php');
foreach ((array)require __DIR__ . '/../../config/services.php' as $name => $definition) {
Container::add($name, $definition);
}
Config::save('backends_file', __DIR__ . '/../Fixtures/test_servers.yaml');
$this->assertInstanceOf(
PlexClient::class,
getBackend('test_plex'),
'It should return correct backend client.'
);
$this->expectException(RuntimeException::class);
getBackend('not_set');
}
public function test_makeBackend()
{
Container::init();
Config::init(require __DIR__ . '/../../config/config.php');
foreach ((array)require __DIR__ . '/../../config/services.php' as $name => $definition) {
Container::add($name, $definition);
}
Config::save('backends_file', __DIR__ . '/../Fixtures/test_servers.yaml');
$exception = null;
try {
makeBackend([], 'foo');
} catch (\Throwable $e) {
$exception = $e;
} finally {
$this->assertInstanceOf(
InvalidArgumentException::class,
$exception,
'Should throw exception when no type is given.'
);
$this->assertStringContainsString('No backend type was set.', $exception?->getMessage());
}
$exception = null;
try {
makeBackend(['type' => 'plex'], 'foo');
} catch (\Throwable $e) {
$exception = $e;
} finally {
$this->assertInstanceOf(
InvalidArgumentException::class,
$exception,
'Should throw exception when no url is given.'
);
$this->assertStringContainsString('No backend url was set.', $exception?->getMessage());
}
$exception = null;
try {
makeBackend(['type' => 'far', 'url' => 'http://test.example.invalid'], 'foo');
} catch (\Throwable $e) {
$exception = $e;
} finally {
$this->assertInstanceOf(
InvalidArgumentException::class,
$exception,
'Should throw exception when no type is not supported.'
);
$this->assertStringContainsString('Unexpected client type', $exception?->getMessage());
}
$data = Yaml::parseFile(__DIR__ . '/../Fixtures/test_servers.yaml');
$this->assertInstanceOf(
PlexClient::class,
makeBackend($data['test_plex'], 'test_plex'),
'It should return correct backend client.'
);
}
public function test_lw()
{
$exception = new RuntimeException();
$exception->addContext('foo', 'bar');
$this->assertSame(
[AppExceptionInterface::class => ['foo' => 'bar']],
lw('test', [], $exception)['context'],
'it should return the added AppContext'
);
$this->assertSame(
['bar' => 'foo'],
lw('test', ['bar' => 'foo'], new \RuntimeException())['context'],
'If exception is not AppExceptionInterface, it should return same data.'
);
$exception = new DBLayerException();
/** @noinspection SqlResolve */
$exception->setInfo('SELECT * FROM foo WHERE id = :id', ['id' => 1], [], 122);
/** @noinspection SqlResolve */
$this->assertSame(
[
'bar' => 'foo',
DBLayer::class => [
'query' => 'SELECT * FROM foo WHERE id = :id',
'bind' => ['id' => 1],
'error' => [],
],
],
lw('test', ['bar' => 'foo'], $exception)['context'],
'If exception is not AppExceptionInterface, it should return same data.'
);
$this->assertSame(
['bar' => 'foo'],
lw('test', ['bar' => 'foo'])['context'],
'If no exception is given, it should return same data.'
);
}
public function test_commandContext()
{
$_ENV['IN_CONTAINER'] = true;
$this->assertSame(
'docker exec -ti watchstate console',
trim(commandContext()),
'It should return correct command context. When in container.'
);
unset($_ENV['IN_CONTAINER']);
$_ENV['IN_CONTAINER'] = false;
$this->assertSame(
$_SERVER['argv'][0] ?? 'php bin/console',
trim(commandContext()),
'If not in container, it should return argv[0] or defaults to php bin/console.'
);
unset($_ENV['IN_CONTAINER']);
}
} }

View File

@@ -5,10 +5,11 @@ declare(strict_types=1);
namespace Tests\Mappers\Import; namespace Tests\Mappers\Import;
use App\Libs\Database\DatabaseInterface as iDB; use App\Libs\Database\DatabaseInterface as iDB;
use App\Libs\Database\DBLayer;
use App\Libs\Database\PDO\PDOAdapter; use App\Libs\Database\PDO\PDOAdapter;
use App\Libs\Entity\StateEntity; use App\Libs\Entity\StateEntity;
use App\Libs\Entity\StateInterface as iState; use App\Libs\Entity\StateInterface as iState;
use App\Libs\Exceptions\DatabaseException; use App\Libs\Exceptions\DBAdapterException;
use App\Libs\Extends\LogMessageProcessor; use App\Libs\Extends\LogMessageProcessor;
use App\Libs\Guid; use App\Libs\Guid;
use App\Libs\Mappers\ImportInterface; use App\Libs\Mappers\ImportInterface;
@@ -51,7 +52,7 @@ abstract class AbstractTestsMapper extends TestCase
$this->logger->pushHandler($this->handler); $this->logger->pushHandler($this->handler);
Guid::setLogger($this->logger); Guid::setLogger($this->logger);
$this->db = new PDOAdapter($this->logger, new PDO('sqlite::memory:')); $this->db = new PDOAdapter($this->logger, new DBLayer(new PDO('sqlite::memory:')));
$this->db->migrations('up'); $this->db->migrations('up');
$this->mapper = $this->setupMapper(); $this->mapper = $this->setupMapper();
@@ -529,7 +530,7 @@ abstract class AbstractTestsMapper extends TestCase
{ {
$testEpisode = new StateEntity($this->testEpisode); $testEpisode = new StateEntity($this->testEpisode);
$testEpisode->episode = 0; $testEpisode->episode = 0;
$this->expectException(DatabaseException::class); $this->expectException(DBAdapterException::class);
$this->db->commit([$testEpisode]); $this->db->commit([$testEpisode]);
} }
@@ -540,7 +541,7 @@ abstract class AbstractTestsMapper extends TestCase
{ {
$testEpisode = new StateEntity($this->testEpisode); $testEpisode = new StateEntity($this->testEpisode);
$testEpisode->episode = 0; $testEpisode->episode = 0;
$this->expectException(DatabaseException::class); $this->expectException(DBAdapterException::class);
$this->db->insert($testEpisode); $this->db->insert($testEpisode);
} }
@@ -551,7 +552,7 @@ abstract class AbstractTestsMapper extends TestCase
{ {
$testEpisode = new StateEntity($this->testEpisode); $testEpisode = new StateEntity($this->testEpisode);
$testEpisode->episode = 0; $testEpisode->episode = 0;
$this->expectException(DatabaseException::class); $this->expectException(DBAdapterException::class);
$this->db->update($testEpisode); $this->db->update($testEpisode);
} }

View File

@@ -2,6 +2,10 @@
declare(strict_types=1); declare(strict_types=1);
if (false === defined('IN_TEST_MODE')) {
define('IN_TEST_MODE', true);
}
require __DIR__ . '/../pre_init.php'; require __DIR__ . '/../pre_init.php';
if (!file_exists(__DIR__ . '/../vendor/autoload.php')) { if (!file_exists(__DIR__ . '/../vendor/autoload.php')) {