As the API stabilizing, we started adding tests.

This commit is contained in:
Abdulmhsen B. A. A
2022-02-19 20:56:51 +03:00
parent 3d8fdc20ac
commit 8dd39b7e6e
14 changed files with 2626 additions and 361 deletions

View File

@@ -5,3 +5,4 @@
!./docker/config/.gitignore
./var/*
!./var/.gitignore
.phpunit.result.cache

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/.idea/*
/vendor/*
.phpunit.result.cache

View File

@@ -10,6 +10,9 @@
"*": "dist"
}
},
"scripts": {
"test": "vendor/bin/phpunit --colors=always"
},
"require": {
"php": ">=8.1",
"ext-pdo": "*",
@@ -33,7 +36,13 @@
"require-dev": {
"roave/security-advisories": "dev-latest",
"symfony/var-dumper": "^6.0",
"perftools/php-profiler": "^1.0"
"perftools/php-profiler": "^1.0",
"phpunit/phpunit": "^9.5"
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests"
}
},
"autoload": {
"files": [

1955
composer.lock generated

File diff suppressed because it is too large Load Diff

11
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd"
bootstrap="tests/bootstrap.php" colors="true">
<testsuites>
<testsuite name="WatchState Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<logging/>
</phpunit>

View File

@@ -2,15 +2,7 @@
declare(strict_types=1);
use App\Libs\Config;
use App\Libs\Container;
use App\Libs\HttpException;
use App\Libs\Servers\ServerInterface;
use App\Libs\Storage\StorageInterface;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
error_reporting(E_ALL);
ini_set('error_reporting', 'On');
@@ -41,82 +33,8 @@ if (!file_exists(__DIR__ . '/../vendor/autoload.php')) {
require __DIR__ . '/../vendor/autoload.php';
$fn = function (ServerRequestInterface $request): ResponseInterface {
try {
if (true !== Config::get('webhook.enabled', false)) {
throw new HttpException('Webhook is disabled via config.', 500);
}
if (null === Config::get('webhook.apikey', null)) {
throw new HttpException('No webhook.apikey is set in config.', 500);
}
// -- get apikey from header or query.
$apikey = $request->getHeaderLine('x-apikey');
if (empty($apikey)) {
$apikey = ag($request->getQueryParams(), 'apikey', '');
if (empty($apikey)) {
throw new HttpException('No API key was given.', 400);
}
}
if (!hash_equals(Config::get('webhook.apikey'), $apikey)) {
throw new HttpException('Invalid API key was given.', 401);
}
if (null === ($type = ag($request->getQueryParams(), 'type', null))) {
throw new HttpException('No type was given via type= query.', 400);
}
$types = Config::get('supported', []);
if (null === ($backend = ag($types, $type))) {
throw new HttpException('Invalid server type was given.', 400);
}
$class = new ReflectionClass($backend);
if (!$class->implementsInterface(ServerInterface::class)) {
throw new HttpException('Invalid Parser Class.', 500);
}
/** @var ServerInterface $backend */
$entity = $backend::parseWebhook($request);
if (null === $entity || !$entity->hasGuids()) {
return new Response(status: 200, headers: ['X-Status' => 'No GUIDs.']);
}
$storage = Container::get(StorageInterface::class);
if (null === ($backend = $storage->get($entity))) {
$storage->insert($entity);
return jsonResponse(status: 200, body: $entity->getAll());
}
if ($backend->updated > $entity->updated) {
return new Response(
status: 200,
headers: ['X-Status' => 'Entity date is older than what available in storage.']
);
}
if ($backend->apply($entity)->isChanged()) {
$backend = $storage->update($backend);
return jsonResponse(status: 200, body: $backend->getAll());
}
return new Response(status: 200, headers: ['X-Status' => 'Entity is unchanged.']);
} catch (HttpException $e) {
Container::get(LoggerInterface::class)->error($e->getMessage());
if (200 === $e->getCode()) {
return new Response(status: $e->getCode(), headers: ['X-Status' => $e->getMessage()]);
}
return jsonResponse(status: $e->getCode(), body: ['error' => true, 'message' => $e->getMessage()]);
(new App\Libs\KernelConsole())->boot()->runHttp(
function (ServerRequestInterface $request) {
return serveHttpRequest($request);
}
};
(new App\Libs\KernelConsole())->boot()->runHttp($fn);
);

View File

@@ -136,6 +136,12 @@ final class StateEntity implements StateInterface
return $this;
}
public function updateOriginal(): StateInterface
{
$this->data = $this->getAll();
return $this;
}
private function isEqual(StateInterface $entity): bool
{
foreach ($this->getAll() as $key => $val) {

View File

@@ -81,4 +81,5 @@ interface StateInterface
*/
public function apply(StateInterface $entity, bool $guidOnly = false): StateInterface;
public function updateOriginal(): StateInterface;
}

View File

@@ -6,6 +6,7 @@ namespace App\Libs\Storage\PDO;
use App\Libs\Container;
use App\Libs\Entity\StateInterface;
use App\Libs\Storage\StorageException;
use App\Libs\Storage\StorageInterface;
use Closure;
use DateTimeInterface;
@@ -14,7 +15,6 @@ use PDO;
use PDOException;
use PDOStatement;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -28,38 +28,26 @@ final class PDOAdapter implements StorageInterface
];
private PDO|null $pdo = null;
private string|null $driver = null;
private bool $viaCommit = false;
private PDOStatement|null $stmtInsert = null;
private PDOStatement|null $stmtUpdate = null;
private PDOStatement|null $stmtDelete = null;
/**
* Cache Prepared Statements.
*
* @var array<array-key, PDOStatement>
*/
private array $stmt = [
'insert' => null,
'update' => null,
];
public function __construct(private LoggerInterface $logger)
{
}
public function getAll(DateTimeInterface|null $date = null): array
{
$arr = [];
$sql = "SELECT * FROM state";
if (null !== $date) {
$sql .= ' WHERE updated > ' . $date->getTimestamp();
}
foreach ($this->pdo->query($sql) as $row) {
$arr[] = Container::get(StateInterface::class)::fromArray($row);
}
return $arr;
}
public function setUp(array $opts): StorageInterface
{
if (null === ($opts['dsn'] ?? null)) {
throw new RuntimeException('No storage.opts.dsn (Data Source Name) was provided.');
throw new StorageException('No storage.opts.dsn (Data Source Name) was provided.', 10);
}
$this->pdo = new PDO(
@@ -75,13 +63,13 @@ final class PDOAdapter implements StorageInterface
)
);
$this->driver = $this->getDriver();
$driver = $this->getDriver();
if (!in_array($this->driver, $this->supported)) {
throw new RuntimeException(sprintf('%s Driver is not supported.', $this->driver));
if (!in_array($driver, $this->supported)) {
throw new StorageException(sprintf('%s Driver is not supported.', $driver), 11);
}
if (null !== ($exec = ag($opts, "exec.{$this->driver}")) && is_array($exec)) {
if (null !== ($exec = ag($opts, "exec.{$driver}")) && is_array($exec)) {
foreach ($exec as $cmd) {
$this->pdo->exec($cmd);
}
@@ -90,17 +78,10 @@ final class PDOAdapter implements StorageInterface
return $this;
}
public function setLogger(LoggerInterface $logger): StorageInterface
{
$this->logger = $logger;
return $this;
}
public function insert(StateInterface $entity): StateInterface
{
if (null === $this->pdo) {
throw new RuntimeException('Setup(): method was not called.');
throw new StorageException('Setup(): method was not called.', StorageException::SETUP_NOT_CALLED);
}
try {
@@ -111,24 +92,24 @@ final class PDOAdapter implements StorageInterface
}
if (null !== $data['id']) {
throw new RuntimeException(
sprintf('Trying to insert already saved entity #%s', $data['id'])
throw new StorageException(
sprintf('Trying to insert already saved entity #%s', $data['id']), 21
);
}
unset($data['id']);
if (null === $this->stmtInsert) {
$this->stmtInsert = $this->pdo->prepare(
$this->pdoInsert('state', array_keys($data))
if (null === ($this->stmt['insert'] ?? null)) {
$this->stmt['insert'] = $this->pdo->prepare(
$this->pdoInsert('state', StateInterface::ENTITY_KEYS)
);
}
$this->stmtInsert->execute($data);
$this->stmt['insert']->execute($data);
$entity->id = (int)$this->pdo->lastInsertId();
} catch (PDOException $e) {
$this->stmtInsert = null;
$this->stmt['insert'] = null;
if (false === $this->viaCommit) {
$this->logger->error($e->getMessage(), $entity->meta ?? []);
return $entity;
@@ -136,13 +117,56 @@ final class PDOAdapter implements StorageInterface
throw $e;
}
return $entity;
return $entity->updateOriginal();
}
public function get(StateInterface $entity): StateInterface|null
{
if (null === $this->pdo) {
throw new StorageException('Setup(): method was not called.', StorageException::SETUP_NOT_CALLED);
}
$arr = array_intersect_key(
$entity->getAll(),
array_flip(StateInterface::ENTITY_GUIDS)
);
if (null !== $entity->id) {
$arr['id'] = $entity->id;
}
return $this->matchAnyId($arr, $entity);
}
public function getAll(DateTimeInterface|null $date = null, StateInterface|null $class = null): array
{
if (null === $this->pdo) {
throw new StorageException('Setup(): method was not called.', StorageException::SETUP_NOT_CALLED);
}
$arr = [];
$sql = 'SELECT * FROM state';
if (null !== $date) {
$sql .= ' WHERE updated > ' . $date->getTimestamp();
}
if (null === $class) {
$class = Container::get(StateInterface::class);
}
foreach ($this->pdo->query($sql) as $row) {
$arr[] = $class::fromArray($row);
}
return $arr;
}
public function update(StateInterface $entity): StateInterface
{
if (null === $this->pdo) {
throw new RuntimeException('Setup(): method was not called.');
throw new StorageException('Setup(): method was not called.', StorageException::SETUP_NOT_CALLED);
}
try {
@@ -153,16 +177,18 @@ final class PDOAdapter implements StorageInterface
}
if (null === $data['id']) {
throw new RuntimeException('Trying to update unsaved entity');
throw new StorageException('Trying to update unsaved entity', 51);
}
if (null === $this->stmtUpdate) {
$this->stmtUpdate = $this->pdo->prepare($this->pdoUpdate('state', array_keys($data)));
if (null === ($this->stmt['update'] ?? null)) {
$this->stmt['update'] = $this->pdo->prepare(
$this->pdoUpdate('state', StateInterface::ENTITY_KEYS)
);
}
$this->stmtUpdate->execute($data);
$this->stmt['update']->execute($data);
} catch (PDOException $e) {
$this->stmtUpdate = null;
$this->stmt['update'] = null;
if (false === $this->viaCommit) {
$this->logger->error($e->getMessage(), $entity->meta ?? []);
return $entity;
@@ -170,31 +196,35 @@ final class PDOAdapter implements StorageInterface
throw $e;
}
return $entity;
return $entity->updateOriginal();
}
public function get(StateInterface $entity): StateInterface|null
public function matchAnyId(array $ids, StateInterface|null $class = null): StateInterface|null
{
if (null === $this->pdo) {
throw new RuntimeException('Setup(): method was not called.');
throw new StorageException('Setup(): method was not called.', StorageException::SETUP_NOT_CALLED);
}
if (null !== $entity->id) {
$stmt = $this->pdo->query("SELECT * FROM state WHERE id = " . (int)$entity->id);
if (null === $class) {
$class = Container::get(StateInterface::class);
}
if (null !== ($ids['id'] ?? null)) {
$stmt = $this->pdo->query("SELECT * FROM state WHERE id = " . (int)$ids['id']);
if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) {
return null;
}
return Container::get(StateInterface::class)::fromArray($row);
return $class::fromArray($row);
}
$cond = $where = [];
foreach (StateInterface::ENTITY_GUIDS as $key) {
if (null === $entity->{$key}) {
if (null === ($ids[$key] ?? null)) {
continue;
}
$cond[$key] = $entity->{$key};
$cond[$key] = $ids[$key];
}
if (empty($cond)) {
@@ -202,106 +232,47 @@ final class PDOAdapter implements StorageInterface
}
foreach ($cond as $key => $_) {
$where[] = $this->escapeIdentifier($key) . ' = :' . $key;
$where[] = $key . ' = :' . $key;
}
$sqlWhere = implode(' OR ', $where);
$stmt = $this->pdo->prepare(
sprintf(
"SELECT * FROM %s WHERE %s LIMIT 1",
$this->escapeIdentifier('state'),
$sqlWhere
)
);
$cachedKey = md5($sqlWhere);
if (false === $stmt->execute($cond)) {
throw new RuntimeException('Unable to prepare sql statement');
}
try {
if (null === ($this->stmt[$cachedKey] ?? null)) {
$this->stmt[$cachedKey] = $this->pdo->prepare("SELECT * FROM state WHERE {$sqlWhere}");
}
if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) {
return null;
}
if (false === $this->stmt[$cachedKey]->execute($cond)) {
$this->stmt[$cachedKey] = null;
throw new StorageException('Failed to execute sql query.', 61);
}
return Container::get(StateInterface::class)::fromArray($row);
}
public function matchAnyId(array $ids): StateInterface|null
{
if (null === $this->pdo) {
throw new RuntimeException('Setup(): method was not called.');
}
if (null !== ($ids['id'] ?? null)) {
$stmt = $this->pdo->prepare(
sprintf(
'SELECT * FROM %s WHERE %s = :id LIMIT 1',
$this->escapeIdentifier('state'),
$this->escapeIdentifier('id'),
)
);
if (false === ($stmt->execute(['id' => $ids['id']]))) {
if (false === ($row = $this->stmt[$cachedKey]->fetch(PDO::FETCH_ASSOC))) {
return null;
}
if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) {
return null;
}
return Container::get(StateInterface::class)::fromArray($row);
return $class::fromArray($row);
} catch (PDOException|StorageException $e) {
$this->stmt[$cachedKey] = null;
throw $e;
}
$cond = $where = [];
foreach ($ids as $_val) {
if (null === $_val || !str_starts_with($_val, 'guid_')) {
continue;
}
[$key, $val] = explode('://', $_val);
$cond[$key] = $val;
}
if (empty($cond)) {
return null;
}
foreach ($cond as $key => $_) {
$where[] = $this->escapeIdentifier($key) . ' = :' . $key;
}
$sqlWhere = implode(' OR ', $where);
$stmt = $this->pdo->prepare(
sprintf(
"SELECT * FROM %s WHERE %s LIMIT 1",
$this->escapeIdentifier('state'),
$sqlWhere
)
);
if (false === $stmt->execute($cond)) {
throw new RuntimeException('Unable to prepare sql statement');
}
if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) {
return null;
}
return Container::get(StateInterface::class)::fromArray($row);
}
public function remove(StateInterface $entity): bool
{
if (null === $this->pdo) {
throw new StorageException('Setup(): method was not called.', StorageException::SETUP_NOT_CALLED);
}
if (null === $entity->id && !$entity->hasGuids()) {
return false;
}
try {
if (null === $entity->id) {
if (null === $dbEntity = $this->get($entity)) {
if (null === ($dbEntity = $this->get($entity))) {
return false;
}
$id = $dbEntity->id;
@@ -309,20 +280,9 @@ final class PDOAdapter implements StorageInterface
$id = $entity->id;
}
if (null === $this->stmtDelete) {
$this->stmtDelete = $this->pdo->prepare(
sprintf(
'DELETE FROM %s WHERE %s = :id',
$this->escapeIdentifier('state'),
$this->escapeIdentifier('id'),
)
);
}
$this->stmtDelete->execute(['id' => $id]);
$this->pdo->query('DELETE FROM state WHERE id = ' . (int)$id);
} catch (PDOException $e) {
$this->logger->error($e->getMessage());
$this->stmtDelete = null;
return false;
}
@@ -332,7 +292,7 @@ final class PDOAdapter implements StorageInterface
public function commit(array $entities): array
{
if (null === $this->pdo) {
throw new RuntimeException('Setup(): method was not called.');
throw new StorageException('Setup(): method was not called.', StorageException::SETUP_NOT_CALLED);
}
return $this->transactional(function () use ($entities) {
@@ -377,6 +337,45 @@ final class PDOAdapter implements StorageInterface
});
}
public function migrations(string $dir, InputInterface $input, OutputInterface $output, array $opts = []): mixed
{
if (null === $this->pdo) {
throw new StorageException('Setup(): method was not called.', StorageException::SETUP_NOT_CALLED);
}
$class = new PDOMigrations($this->pdo);
return match (strtolower($dir)) {
StorageInterface::MIGRATE_UP => $class->up($input, $output),
StorageInterface::MIGRATE_DOWN => $class->down($output),
default => throw new StorageException(sprintf('Unknown direction \'%s\' was given.', $dir), 91),
};
}
/**
* @throws Exception
*/
public function makeMigration(string $name, OutputInterface $output, array $opts = []): mixed
{
if (null === $this->pdo) {
throw new StorageException('Setup(): method was not called.');
}
return (new PDOMigrations($this->pdo))->make($name, $output);
}
public function maintenance(InputInterface $input, OutputInterface $output, array $opts = []): mixed
{
return (new PDOMigrations($this->pdo))->runMaintenance();
}
public function setLogger(LoggerInterface $logger): StorageInterface
{
$this->logger = $logger;
return $this;
}
/**
* Wrap Transaction.
*
@@ -409,6 +408,22 @@ final class PDOAdapter implements StorageInterface
}
}
/**
* Get PDO Driver.
*
* @return string
*/
private function getDriver(): string
{
$driver = $this->pdo->getAttribute($this->pdo::ATTR_DRIVER_NAME);
if (empty($driver) || !is_string($driver)) {
$driver = 'unknown';
}
return strtolower($driver);
}
/**
* Generate SQL Insert Statement.
*
@@ -418,7 +433,7 @@ final class PDOAdapter implements StorageInterface
*/
private function pdoInsert(string $table, array $columns): string
{
$queryString = 'INSERT INTO ' . $this->escapeIdentifier($table) . ' (%{columns}) VALUES(%{values})';
$queryString = "INSERT INTO {$table} (%(columns)) VALUES(%(values))";
$sql_columns = $sql_placeholder = [];
@@ -427,12 +442,12 @@ final class PDOAdapter implements StorageInterface
continue;
}
$sql_columns[] = $this->escapeIdentifier($column, true);
$sql_placeholder[] = ':' . $this->escapeIdentifier($column, false);
$sql_columns[] = $column;
$sql_placeholder[] = ':' . $column;
}
$queryString = str_replace(
['%{columns}', '%{values}'],
['%(columns)', '%(values)'],
[implode(', ', $sql_columns), implode(', ', $sql_placeholder)],
$queryString
);
@@ -449,11 +464,8 @@ final class PDOAdapter implements StorageInterface
*/
private function pdoUpdate(string $table, array $columns): string
{
$queryString = sprintf(
'UPDATE %s SET ${place} = ${holder} WHERE %s = :id',
$this->escapeIdentifier($table, true),
$this->escapeIdentifier('id', true)
);
/** @noinspection SqlWithoutWhere */
$queryString = "UPDATE {$table} SET %(place) = %(holder) WHERE id = :id";
$placeholders = [];
@@ -461,95 +473,14 @@ final class PDOAdapter implements StorageInterface
if ('id' === $column) {
continue;
}
$placeholders[] = sprintf(
'%1$s = :%2$s',
$this->escapeIdentifier($column, true),
$this->escapeIdentifier($column, false)
);
$placeholders[] = sprintf('%1$s = :%1$s', $column);
}
return trim(str_replace('${place} = ${holder}', implode(', ', $placeholders), $queryString));
}
private function escapeIdentifier(string $text, bool $quote = true): string
{
// table or column has to be valid ASCII name.
// this is opinionated, but we only allow [a-zA-Z0-9_] in column/table name.
if (!preg_match('#\w#', $text)) {
throw new RuntimeException(
sprintf(
'Invalid identifier "%s": Column/table must be valid ASCII code.',
$text
)
);
}
// The first character cannot be [0-9]:
if (preg_match('/^\d/', $text)) {
throw new RuntimeException(
sprintf(
'Invalid identifier "%s": Must begin with a letter or underscore.',
$text
)
);
}
if (!$quote) {
return $text;
}
return match ($this->driver) {
'mssql' => '[' . $text . ']',
'mysql' => '`' . $text . '`',
default => '"' . $text . '"',
};
return trim(str_replace('%(place) = %(holder)', implode(', ', $placeholders), $queryString));
}
public function __destruct()
{
$this->stmtDelete = $this->stmtUpdate = $this->stmtInsert = null;
}
public function migrations(string $dir, InputInterface $input, OutputInterface $output, array $opts = []): mixed
{
if (null === $this->pdo) {
throw new RuntimeException('Setup(): method was not called.');
}
$class = new PDOMigrations($this->pdo);
return match ($dir) {
StorageInterface::MIGRATE_UP => $class->up($input, $output),
StorageInterface::MIGRATE_DOWN => $class->down($output),
default => throw new RuntimeException(sprintf('Unknown direction \'%s\' was given.', $dir)),
};
}
/**
* @throws Exception
*/
public function makeMigration(string $name, OutputInterface $output, array $opts = []): void
{
if (null === $this->pdo) {
throw new RuntimeException('Setup(): method was not called.');
}
(new PDOMigrations($this->pdo))->make($name, $output);
}
public function maintenance(InputInterface $input, OutputInterface $output, array $opts = []): mixed
{
return (new PDOMigrations($this->pdo))->runMaintenance();
}
private function getDriver(): string
{
$driver = $this->pdo->getAttribute($this->pdo::ATTR_DRIVER_NAME);
if (empty($driver) || !is_string($driver)) {
$driver = 'unknown';
}
return strtolower($driver);
$this->stmt = [];
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Libs\Storage;
use RuntimeException;
class StorageException extends RuntimeException
{
public const SETUP_NOT_CALLED = 1;
}

View File

@@ -25,14 +25,6 @@ interface StorageInterface
*/
public function setUp(array $opts): self;
/**
* Inject Logger.
*
* @param LoggerInterface $logger
* @return $this
*/
public function setLogger(LoggerInterface $logger): self;
/**
* Insert Entity immediately.
*
@@ -42,6 +34,25 @@ interface StorageInterface
*/
public function insert(StateInterface $entity): StateInterface;
/**
* Get Entity.
*
* @param StateInterface $entity
*
* @return StateInterface|null
*/
public function get(StateInterface $entity): StateInterface|null;
/**
* Load entities from backend.
*
* @param DateTimeInterface|null $date Get Entities That has changed since given time, if null get all.
* @param StateInterface|null $class Create objects based on given class, if null use default class.
*
* @return array<StateInterface>
*/
public function getAll(DateTimeInterface|null $date = null, StateInterface|null $class = null): array;
/**
* Update Entity immediately.
*
@@ -52,13 +63,14 @@ interface StorageInterface
public function update(StateInterface $entity): StateInterface;
/**
* Get Entity.
* Get Entity Using array of ids.
*
* @param StateInterface $entity
* @param array $ids
* @param StateInterface|null $class Create object based on given class, if null use default class.
*
* @return StateInterface|null
*/
public function get(StateInterface $entity): StateInterface|null;
public function matchAnyId(array $ids, StateInterface|null $class = null): StateInterface|null;
/**
* Remove Entity.
@@ -78,24 +90,6 @@ interface StorageInterface
*/
public function commit(array $entities): array;
/**
* Load All Entities From backend.
*
* @param DateTimeInterface|null $date Get Entities That has changed since given time.
*
* @return array<StateInterface>
*/
public function getAll(DateTimeInterface|null $date = null): array;
/**
* Get Entity Using array of ids.
*
* @param array $ids
*
* @return StateInterface|null
*/
public function matchAnyId(array $ids): StateInterface|null;
/**
* Migrate Backend Storage Schema.
*
@@ -124,6 +118,17 @@ interface StorageInterface
* @param string $name
* @param OutputInterface $output
* @param array $opts
*
* @return mixed can return migration file name in supported cases.
*/
public function makeMigration(string $name, OutputInterface $output, array $opts = []): void;
public function makeMigration(string $name, OutputInterface $output, array $opts = []): mixed;
/**
* Inject Logger.
*
* @param LoggerInterface $logger
* @return $this
*/
public function setLogger(LoggerInterface $logger): self;
}

View File

@@ -3,10 +3,15 @@
declare(strict_types=1);
use App\Libs\Config;
use App\Libs\Container;
use App\Libs\Extends\Date;
use App\Libs\HttpException;
use App\Libs\Servers\ServerInterface;
use App\Libs\Storage\StorageInterface;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
@@ -112,7 +117,7 @@ if (!function_exists('ag_set')) {
if (is_array($at)) {
$at[array_shift($keys)] = $value;
} else {
throw new RuntimeException("Can not set value at this path ($path) because is not array.");
throw new RuntimeException("Can not set value at this path ($path) because its not array.");
}
} else {
$path = array_shift($keys);
@@ -268,3 +273,87 @@ if (!function_exists('httpClientChunks')) {
}
}
}
if (!function_exists('serveHttpRequest')) {
/**
* @throws ReflectionException
*/
function serveHttpRequest(ServerRequestInterface $request): ResponseInterface
{
try {
if (true !== Config::get('webhook.enabled', false)) {
throw new HttpException('Webhook is disabled via config.', 500);
}
if (null === Config::get('webhook.apikey', null)) {
throw new HttpException('No webhook.apikey is set in config.', 500);
}
// -- get apikey from header or query.
$apikey = $request->getHeaderLine('x-apikey');
if (empty($apikey)) {
$apikey = ag($request->getQueryParams(), 'apikey', '');
if (empty($apikey)) {
throw new HttpException('No API key was given.', 400);
}
}
if (!hash_equals(Config::get('webhook.apikey'), $apikey)) {
throw new HttpException('Invalid API key was given.', 401);
}
if (null === ($type = ag($request->getQueryParams(), 'type', null))) {
throw new HttpException('No type was given via type= query.', 400);
}
$types = Config::get('supported', []);
if (null === ($backend = ag($types, $type))) {
throw new HttpException('Invalid server type was given.', 400);
}
$class = new ReflectionClass($backend);
if (!$class->implementsInterface(ServerInterface::class)) {
throw new HttpException('Invalid Parser Class.', 500);
}
/** @var ServerInterface $backend */
$entity = $backend::parseWebhook($request);
if (null === $entity || !$entity->hasGuids()) {
return new Response(status: 200, headers: ['X-Status' => 'No GUIDs.']);
}
$storage = Container::get(StorageInterface::class);
if (null === ($backend = $storage->get($entity))) {
$storage->insert($entity);
return jsonResponse(status: 200, body: $entity->getAll());
}
if ($backend->updated > $entity->updated) {
return new Response(
status: 200,
headers: ['X-Status' => 'Entity date is older than what available in storage.']
);
}
if ($backend->apply($entity)->isChanged()) {
$backend = $storage->update($backend);
return jsonResponse(status: 200, body: $backend->getAll());
}
return new Response(status: 200, headers: ['X-Status' => 'Entity is unchanged.']);
} catch (HttpException $e) {
Container::get(LoggerInterface::class)->error($e->getMessage());
if (200 === $e->getCode()) {
return new Response(status: $e->getCode(), headers: ['X-Status' => $e->getMessage()]);
}
return jsonResponse(status: $e->getCode(), body: ['error' => true, 'message' => $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,313 @@
<?php
declare(strict_types=1);
namespace Tests\Storage;
use App\Libs\Entity\StateEntity;
use App\Libs\Entity\StateInterface;
use App\Libs\Extends\CliLogger;
use App\Libs\Storage\PDO\PDOAdapter;
use App\Libs\Storage\StorageException;
use App\Libs\Storage\StorageInterface;
use DateTimeImmutable;
use Error;
use PDOException;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
class PDOAdapterTest extends TestCase
{
private array $testEpisode = [
'id' => null,
'type' => StateInterface::TYPE_EPISODE,
'updated' => 0,
'watched' => 1,
'meta' => [],
'guid_plex' => StateInterface::TYPE_EPISODE . '/1',
'guid_imdb' => StateInterface::TYPE_EPISODE . '/2',
'guid_tvdb' => StateInterface::TYPE_EPISODE . '/3',
'guid_tmdb' => StateInterface::TYPE_EPISODE . '/4',
'guid_tvmaze' => StateInterface::TYPE_EPISODE . '/5',
'guid_tvrage' => StateInterface::TYPE_EPISODE . '/6',
'guid_anidb' => StateInterface::TYPE_EPISODE . '/7',
];
private array $testMovie = [
'id' => null,
'type' => StateInterface::TYPE_MOVIE,
'updated' => 1,
'watched' => 1,
'meta' => [],
'guid_plex' => StateInterface::TYPE_MOVIE . '/10',
'guid_imdb' => StateInterface::TYPE_MOVIE . '/20',
'guid_tvdb' => StateInterface::TYPE_MOVIE . '/30',
'guid_tmdb' => StateInterface::TYPE_MOVIE . '/40',
'guid_tvmaze' => StateInterface::TYPE_MOVIE . '/50',
'guid_tvrage' => StateInterface::TYPE_MOVIE . '/60',
'guid_anidb' => StateInterface::TYPE_MOVIE . '/70',
];
private StorageInterface|null $storage = null;
public function setUp(): void
{
$this->output = new NullOutput();
$this->input = new ArrayInput([]);
$this->storage = new PDOAdapter(new CliLogger($this->output));
$this->storage->setUp(['dsn' => 'sqlite::memory:']);
$this->storage->migrations('up', $this->input, $this->output);
}
/** StorageInterface::setUp */
public function test_setup_throw_exception_if_no_dsn(): void
{
$this->expectException(StorageException::class);
$this->expectExceptionCode(10);
$storage = new PDOAdapter(new CliLogger($this->output));
$storage->setUp([]);
}
public function test_setup_throw_exception_if_invalid_dsn(): void
{
$this->expectException(PDOException::class);
$storage = new PDOAdapter(new CliLogger($this->output));
$storage->setUp(['dsn' => 'not_real_driver::foo']);
}
/** StorageInterface::insert */
public function test_insert_call_without_setup_exception(): void
{
$this->expectException(StorageException::class);
$this->expectExceptionCode(StorageException::SETUP_NOT_CALLED);
$storage = new PDOAdapter(new CliLogger($this->output));
$storage->insert(new StateEntity([]));
}
public function test_insert_throw_exception_if_has_id(): void
{
$this->expectException(StorageException::class);
$this->expectExceptionCode(21);
$item = new StateEntity($this->testEpisode);
$this->storage->insert($item);
$this->storage->insert($item);
}
public function test_insert_successful(): void
{
$item = $this->storage->insert(new StateEntity($this->testEpisode));
$this->assertSame(1, $item->id);
}
/** StorageInterface::get */
public function test_get_call_without_setup_exception(): void
{
$this->expectException(StorageException::class);
$this->expectExceptionCode(StorageException::SETUP_NOT_CALLED);
$storage = new PDOAdapter(new CliLogger($this->output));
$storage->get(new StateEntity([]));
}
public function test_get_conditions(): void
{
$item = new StateEntity($this->testEpisode);
// -- db should be empty at this stage. as such we expect null.
$this->assertNull($this->storage->get($item));
// -- insert and return object and assert it's the same
$modified = $this->storage->insert(clone $item);
$this->assertSame($modified->getAll(), $this->storage->get($item)->getAll());
// -- look up based on id
$this->assertSame($modified->getAll(), $this->storage->get($modified)->getAll());
}
/** StorageInterface::getAll */
public function test_getAll_call_without_setup_exception(): void
{
$this->expectException(StorageException::class);
$this->expectExceptionCode(StorageException::SETUP_NOT_CALLED);
$storage = new PDOAdapter(new CliLogger($this->output));
$storage->getAll();
}
public function test_getAll_call_without_initialized_container(): void
{
$this->expectException(Error::class);
$this->expectExceptionMessage('Call to a member function');
$this->storage->getAll();
}
public function test_getAll_conditions(): void
{
$item = new StateEntity($this->testEpisode);
$this->assertSame([], $this->storage->getAll(class: $item));
$this->storage->insert($item);
$this->assertCount(1, $this->storage->getAll(class: $item));
// -- future date should be 0.
$this->assertCount(0, $this->storage->getAll(date: new DateTimeImmutable('now'), class: $item));
}
/** StorageInterface::update */
public function test_update_call_without_setup_exception(): void
{
$this->expectException(StorageException::class);
$this->expectExceptionCode(StorageException::SETUP_NOT_CALLED);
$storage = new PDOAdapter(new CliLogger($this->output));
$storage->update(new StateEntity([]));
}
public function test_update_call_without_id_exception(): void
{
$this->expectException(StorageException::class);
$this->expectExceptionCode(51);
$item = new StateEntity($this->testEpisode);
$this->storage->update($item);
}
public function test_update_conditions(): void
{
$item = $this->storage->insert(new StateEntity($this->testEpisode));
$item->guid_plex = StateInterface::TYPE_EPISODE . '/1000';
$updatedItem = $this->storage->update($item);
$this->assertSame($item, $updatedItem);
$this->assertSame($updatedItem->getAll(), $this->storage->get($item)->getAll());
}
/** StorageInterface::update */
public function test_matchAnyId_call_without_setup_exception(): void
{
$this->expectException(StorageException::class);
$this->expectExceptionCode(StorageException::SETUP_NOT_CALLED);
$storage = new PDOAdapter(new CliLogger($this->output));
$storage->matchAnyId([]);
}
public function test_matchAnyId_call_without_initialized_container(): void
{
$this->expectException(Error::class);
$this->expectExceptionMessage('Call to a member function');
$this->storage->matchAnyId([]);
}
public function test_matchAnyId_conditions(): void
{
$item1 = new StateEntity($this->testEpisode);
$item2 = new StateEntity($this->testMovie);
$this->assertNull(
$this->storage->matchAnyId(
array_intersect_key($item1->getAll(), array_flip(StateInterface::ENTITY_GUIDS)),
$item1
)
);
$newItem1 = $this->storage->insert($item1);
$newItem2 = $this->storage->insert($item2);
$this->assertSame(
$newItem1->getAll(),
$this->storage->matchAnyId(
array_intersect_key($item1->getAll(), array_flip(StateInterface::ENTITY_GUIDS)),
$item1
)->getAll()
);
$this->assertSame(
$newItem2->getAll(),
$this->storage->matchAnyId(
array_intersect_key($item2->getAll(), array_flip(StateInterface::ENTITY_GUIDS)),
$item2
)->getAll()
);
}
/** StorageInterface::remove */
public function test_remove_call_without_setup_exception(): void
{
$this->expectException(StorageException::class);
$this->expectExceptionCode(StorageException::SETUP_NOT_CALLED);
$storage = new PDOAdapter(new CliLogger($this->output));
$storage->remove(new StateEntity([]));
}
public function test_remove_conditions(): void
{
$item1 = new StateEntity($this->testEpisode);
$item2 = new StateEntity($this->testMovie);
$item3 = new StateEntity([]);
$this->assertFalse($this->storage->remove($item1));
$item1 = $this->storage->insert($item1);
$this->storage->insert($item2);
$this->assertTrue($this->storage->remove($item1));
$this->assertInstanceOf(StateInterface::class, $this->storage->get($item2));
// -- remove without id pointer.
$this->assertTrue($this->storage->remove($item2));
$this->assertFalse($this->storage->remove($item3));
}
/** StorageInterface::commit */
public function test_commit_call_without_setup_exception(): void
{
$this->expectException(StorageException::class);
$this->expectExceptionCode(StorageException::SETUP_NOT_CALLED);
$storage = new PDOAdapter(new CliLogger($this->output));
$storage->commit([]);
}
public function test_commit_conditions(): void
{
$item1 = new StateEntity($this->testEpisode);
$item2 = new StateEntity($this->testMovie);
$this->assertSame(
[
StateInterface::TYPE_MOVIE => ['added' => 1, 'updated' => 0, 'failed' => 0],
StateInterface::TYPE_EPISODE => ['added' => 1, 'updated' => 0, 'failed' => 0],
],
$this->storage->commit([$item1, $item2])
);
$item1->guid_anidb = StateInterface::TYPE_EPISODE . '/1';
$item2->guid_anidb = StateInterface::TYPE_MOVIE . '/1';
$this->assertSame(
[
StateInterface::TYPE_MOVIE => ['added' => 0, 'updated' => 1, 'failed' => 0],
StateInterface::TYPE_EPISODE => ['added' => 0, 'updated' => 1, 'failed' => 0],
],
$this->storage->commit([$item1, $item2])
);
}
/** StorageInterface::migrations */
public function test_migrations_call_without_setup_exception(): void
{
$this->expectException(StorageException::class);
$this->expectExceptionCode(StorageException::SETUP_NOT_CALLED);
$storage = new PDOAdapter(new CliLogger($this->output));
$storage->migrations('f', new ArrayInput([]), new NullOutput());
}
public function test_migrations_call_with_wrong_direction_exception(): void
{
$this->expectException(StorageException::class);
$this->expectExceptionCode(91);
$this->storage->migrations('not_dd', new ArrayInput([]), new NullOutput());
}
}

15
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../pre_init.php';
if (!file_exists(__DIR__ . '/../vendor/autoload.php')) {
fwrite(STDERR, 'Composer dependencies are missing. Run the following commands.' . PHP_EOL);
fwrite(STDERR, sprintf('cd %s', dirname(__DIR__)) . PHP_EOL);
fwrite(STDERR, 'composer install --optimize-autoloader' . PHP_EOL);
exit(1);
}
require __DIR__ . '/../vendor/autoload.php';