diff --git a/CHANGELOG b/CHANGELOG index 7330065f..6606a7da 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,9 @@ Version 0.3.0 (2018-??-??) ========================== +New features: +- Support for SQLite3 via PDO + Changes: - Make date strings in TTRSS explicitly UTC diff --git a/README.md b/README.md index 3e94eae4..67bc369f 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The Arsse has the following requirements: - PHP 7.0.7 or later with the following extensions: - [intl](http://php.net/manual/en/book.intl.php), [json](http://php.net/manual/en/book.json.php), [hash](http://php.net/manual/en/book.hash.php), and [pcre](http://php.net/manual/en/book.pcre.php) - [dom](http://php.net/manual/en/book.dom.php), [simplexml](http://php.net/manual/en/book.simplexml.php), and [iconv](http://php.net/manual/en/book.iconv.php) (for picoFeed) - - [sqlite3](http://php.net/manual/en/book.sqlite3.php) + - [sqlite3](http://php.net/manual/en/book.sqlite3.php) or [pdo_sqlite](http://ca1.php.net/manual/en/ref.pdo-sqlite.php) - Privileges to create and run daemon processes on the server ## Installation diff --git a/RoboFile.php b/RoboFile.php index ba936021..d0d1d24a 100644 --- a/RoboFile.php +++ b/RoboFile.php @@ -45,7 +45,7 @@ class RoboFile extends \Robo\Tasks { * See help for the "test" task for more details. */ public function testQuick(array $args): Result { - return $this->test(array_merge(["--exclude-group","slow"], $args)); + return $this->test(array_merge(["--exclude-group", "slow,optional"], $args)); } /** Produces a code coverage report diff --git a/lib/Database.php b/lib/Database.php index ea39f215..c5c4e653 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -27,7 +27,7 @@ class Database { public function __construct($initialize = true) { $driver = Arsse::$conf->dbDriver; - $this->db = new $driver(); + $this->db = $driver::create(); $ver = $this->db->schemaVersion(); if ($initialize && $ver < self::SCHEMA_VERSION) { $this->db->schemaUpdate(self::SCHEMA_VERSION); @@ -415,7 +415,7 @@ class Database { return $f; } - protected function folderValidateMove(string $user, int $id = null, $parent = null, string $name = null) { + protected function folderValidateMove(string $user, $id = null, $parent = null, string $name = null) { $errData = ["action" => $this->caller(), "field" => "parent", 'id' => $parent]; if (!$id) { // the root cannot be moved @@ -467,7 +467,7 @@ class Database { return $parent; } - protected function folderValidateName($name, bool $checkDuplicates = false, int $parent = null): bool { + protected function folderValidateName($name, bool $checkDuplicates = false, $parent = null): bool { $info = ValueInfo::str($name); if ($info & (ValueInfo::NULL | ValueInfo::EMPTY)) { throw new Db\ExceptionInput("missing", ["action" => $this->caller(), "field" => "name"]); @@ -572,7 +572,7 @@ class Database { // add a suitable WHERE condition $q->setWhere("folder in (select folder from folders)"); } - return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); + return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } public function subscriptionRemove(string $user, $id): bool { @@ -1102,7 +1102,7 @@ class Database { $q = $this->articleQuery($user, $context); $q->pushCTE("selected_articles"); $q->setBody("SELECT count(*) from selected_articles"); - return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); + return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } } diff --git a/lib/Db/AbstractDriver.php b/lib/Db/AbstractDriver.php index 74fc25dc..2cd89697 100644 --- a/lib/Db/AbstractDriver.php +++ b/lib/Db/AbstractDriver.php @@ -6,14 +6,14 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Db; +use JKingWeb\Arsse\Arsse; + abstract class AbstractDriver implements Driver { protected $locked = false; protected $transDepth = 0; protected $transStatus = []; - abstract public function prepareArray(string $query, array $paramTypes): Statement; - abstract protected function lock(): bool; - abstract protected function unlock(bool $rollback = false) : bool; + abstract protected function getError(): string; /** @codeCoverageIgnore */ public function schemaVersion(): int { @@ -25,6 +25,54 @@ abstract class AbstractDriver implements Driver { } } + public function schemaUpdate(int $to, string $basePath = null): bool { + $ver = $this->schemaVersion(); + if (!Arsse::$conf->dbAutoUpdate) { + throw new Exception("updateManual", ['version' => $ver, 'driver_name' => $this->driverName()]); + } elseif ($ver >= $to) { + throw new Exception("updateTooNew", ['difference' => ($ver - $to), 'current' => $ver, 'target' => $to, 'driver_name' => $this->driverName()]); + } + $sep = \DIRECTORY_SEPARATOR; + $path = ($basePath ?? \JKingWeb\Arsse\BASE."sql").$sep.static::schemaID().$sep; + // lock the database + $this->savepointCreate(true); + for ($a = $this->schemaVersion(); $a < $to; $a++) { + $this->savepointCreate(); + try { + $file = $path.$a.".sql"; + if (!file_exists($file)) { + throw new Exception("updateFileMissing", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]); + } elseif (!is_readable($file)) { + throw new Exception("updateFileUnreadable", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]); + } + $sql = @file_get_contents($file); + if ($sql===false) { + throw new Exception("updateFileUnusable", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]); // @codeCoverageIgnore + } elseif ($sql==="") { + throw new Exception("updateFileIncomplete", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]); + } + try { + $this->exec($sql); + } catch (\Throwable $e) { + throw new Exception("updateFileError", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a, 'message' => $this->getError()]); + } + if ($this->schemaVersion() != $a+1) { + throw new Exception("updateFileIncomplete", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]); + } + } catch (\Throwable $e) { + // undo any partial changes from the failed update + $this->savepointUndo(); + // commit any successful updates if updating by more than one version + $this->savepointRelease(); + // throw the error received + throw $e; + } + $this->savepointRelease(); + } + $this->savepointRelease(); + return true; + } + public function begin(bool $lock = false): Transaction { return new Transaction($this, $lock); } diff --git a/lib/Db/AbstractStatement.php b/lib/Db/AbstractStatement.php index 1269a609..57185ee8 100644 --- a/lib/Db/AbstractStatement.php +++ b/lib/Db/AbstractStatement.php @@ -7,29 +7,31 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Db; use JKingWeb\Arsse\Misc\Date; +use JKingWeb\Arsse\Misc\ValueInfo; abstract class AbstractStatement implements Statement { protected $types = []; protected $isNullable = []; abstract public function runArray(array $values = []): Result; + abstract protected function bindValue($value, string $type, int $position): bool; public function run(...$values): Result { return $this->runArray($values); } - public function rebind(...$bindings): bool { - return $this->rebindArray($bindings); + public function retype(...$bindings): bool { + return $this->retypeArray($bindings); } - public function rebindArray(array $bindings, bool $append = false): bool { + public function retypeArray(array $bindings, bool $append = false): bool { if (!$append) { $this->types = []; } foreach ($bindings as $binding) { if (is_array($binding)) { // recursively flatten any arrays, which may be provided for SET or IN() clauses - $this->rebindArray($binding, true); + $this->retypeArray($binding, true); } else { $binding = trim(strtolower($binding)); if (strpos($binding, "strict ")===0) { @@ -50,43 +52,42 @@ abstract class AbstractStatement implements Statement { protected function cast($v, string $t, bool $nullable) { switch ($t) { - case "date": - if (is_null($v) && !$nullable) { - $v = 0; - } - return Date::transform($v, "date"); - case "time": - if (is_null($v) && !$nullable) { - $v = 0; - } - return Date::transform($v, "time"); case "datetime": + $v = Date::transform($v, "sql"); if (is_null($v) && !$nullable) { $v = 0; - } - return Date::transform($v, "sql"); - case "null": - case "integer": - case "float": - case "binary": - case "string": - case "boolean": - if ($t=="binary") { - $t = "string"; - } - if ($v instanceof \DateTimeInterface) { - if ($t=="string") { - return Date::transform($v, "sql"); - } else { - $v = $v->getTimestamp(); - settype($v, $t); - } - } else { - settype($v, $t); + $v = Date::transform($v, "sql"); } return $v; + case "integer": + return ValueInfo::normalize($v, ValueInfo::T_INT | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); + case "float": + return ValueInfo::normalize($v, ValueInfo::T_FLOAT | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); + case "binary": + case "string": + return ValueInfo::normalize($v, ValueInfo::T_STRING | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); + case "boolean": + $v = ValueInfo::normalize($v, ValueInfo::T_BOOL | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); + return is_null($v) ? $v : (int) $v; default: throw new Exception("paramTypeUnknown", $type); // @codeCoverageIgnore } } + + protected function bindValues(array $values, int $offset = 0): int { + $a = $offset; + foreach ($values as $value) { + if (is_array($value)) { + // recursively flatten any arrays, which may be provided for SET or IN() clauses + $a += $this->bindValues($value, $a); + } elseif (array_key_exists($a, $this->types)) { + $value = $this->cast($value, $this->types[$a], $this->isNullable[$a]); + $this->bindValue($value, $this->types[$a], $a+1); + $a++; + } else { + throw new Exception("paramTypeMissing", $a+1); + } + } + return $a - $offset; + } } diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index 5485458f..bf3bb28d 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -13,11 +13,13 @@ interface Driver { const TR_PEND_COMMIT = -1; const TR_PEND_ROLLBACK = -2; - public function __construct(); + public static function create(): Driver; // returns a human-friendly name for the driver (for display in installer, for example) public static function driverName(): string; // returns the version of the scheme of the opened database; if uninitialized should return 0 public function schemaVersion(): int; + // returns the schema set to be used for database set-up + public static function schemaID(): string; // return a Transaction object public function begin(bool $lock = false): Transaction; // manually begin a real or synthetic transactions, with real or synthetic nesting diff --git a/lib/Db/PDODriver.php b/lib/Db/PDODriver.php new file mode 100644 index 00000000..c6ec0d4b --- /dev/null +++ b/lib/Db/PDODriver.php @@ -0,0 +1,47 @@ +db->exec($query); + return true; + } catch (\PDOException $e) { + list($excClass, $excMsg, $excData) = $this->exceptionBuild(); + throw new $excClass($excMsg, $excData); + } + } + + public function query(string $query): Result { + try { + $r = $this->db->query($query); + } catch (\PDOException $e) { + list($excClass, $excMsg, $excData) = $this->exceptionBuild(); + throw new $excClass($excMsg, $excData); + } + $changes = $r->rowCount(); + try { + $lastId = 0; + $lastId = $this->db->lastInsertId(); + } catch (\PDOException $e) { // @codeCoverageIgnore + } + return new PDOResult($r, [$changes, $lastId]); + } + + public function prepareArray(string $query, array $paramTypes): Statement { + try { + $s = $this->db->prepare($query); + } catch (\PDOException $e) { + list($excClass, $excMsg, $excData) = $this->exceptionBuild(); + throw new $excClass($excMsg, $excData); + } + return new PDOStatement($this->db, $s, $paramTypes); + } +} diff --git a/lib/Db/PDOError.php b/lib/Db/PDOError.php new file mode 100644 index 00000000..37341e4f --- /dev/null +++ b/lib/Db/PDOError.php @@ -0,0 +1,44 @@ +st->errorInfo(); + } else { + $err = $this->db->errorInfo(); + } + switch ($err[0]) { + case "23000": + return [ExceptionInput::class, "constraintViolation", $err[2]]; + case "HY000": + // engine-specific errors + switch ($this->db->getAttribute(\PDO::ATTR_DRIVER_NAME)) { + case "sqlite": + switch ($err[1]) { + case \JKingWeb\Arsse\Db\SQLite3\Driver::SQLITE_BUSY: + return [ExceptionTimeout::class, 'general', $err[2]]; + case \JKingWeb\Arsse\Db\SQLite3\Driver::SQLITE_MISMATCH: + return [ExceptionInput::class, 'engineTypeViolation', $err[2]]; + default: + return [Exception::class, "engineErrorGeneral", $err[1]." - ".$err[2]]; + } + // no break + default: + return [Exception::class, "engineErrorGeneral", $err[2]]; // @codeCoverageIgnore + } + // no break + default: + return [Exception::class, "engineErrorGeneral", $err[0].": ".$err[2]]; // @codeCoverageIgnore + } + } + + public function getError(): string { + return (string) $this->db->errorInfo()[2]; + } +} diff --git a/lib/Db/PDOResult.php b/lib/Db/PDOResult.php new file mode 100644 index 00000000..32400e94 --- /dev/null +++ b/lib/Db/PDOResult.php @@ -0,0 +1,49 @@ +rows; + } + + public function lastId() { + return $this->id; + } + + // constructor/destructor + + public function __construct(\PDOStatement $result, array $changes = [0,0]) { + $this->set = $result; + $this->rows = (int) $changes[0]; + $this->id = (int) $changes[1]; + } + + public function __destruct() { + try { + $this->set->closeCursor(); + } catch (\PDOException $e) { // @codeCoverageIgnore + } + unset($this->set); + } + + // PHP iterator methods + + public function valid() { + $this->cur = $this->set->fetch(\PDO::FETCH_ASSOC); + return ($this->cur !== false); + } +} diff --git a/lib/Db/PDOStatement.php b/lib/Db/PDOStatement.php new file mode 100644 index 00000000..cfd9cefc --- /dev/null +++ b/lib/Db/PDOStatement.php @@ -0,0 +1,55 @@ + \PDO::PARAM_INT, + "float" => \PDO::PARAM_STR, + "datetime" => \PDO::PARAM_STR, + "binary" => \PDO::PARAM_LOB, + "string" => \PDO::PARAM_STR, + "boolean" => \PDO::PARAM_BOOL, + ]; + + protected $st; + protected $db; + + public function __construct(\PDO $db, \PDOStatement $st, array $bindings = []) { + $this->db = $db; + $this->st = $st; + $this->retypeArray($bindings); + } + + public function __destruct() { + unset($this->st); + } + + public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result { + $this->st->closeCursor(); + $this->bindValues($values); + try { + $this->st->execute(); + } catch (\PDOException $e) { + list($excClass, $excMsg, $excData) = $this->exceptionBuild(); + throw new $excClass($excMsg, $excData); + } + $changes = $this->st->rowCount(); + try { + $lastId = 0; + $lastId = $this->db->lastInsertId(); + } catch (\PDOException $e) { // @codeCoverageIgnore + } + return new PDOResult($this->st, [$changes, $lastId]); + } + + protected function bindValue($value, string $type, int $position): bool { + return $this->st->bindValue($position, $value, is_null($value) ? \PDO::PARAM_NULL : self::BINDINGS[$type]); + } +} diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index 7c78c787..5b94bc00 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -22,17 +22,14 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function __construct(string $dbFile = null) { // check to make sure required extension is loaded - if (!class_exists("SQLite3")) { + if (!self::requirementsMet()) { throw new Exception("extMissing", self::driverName()); // @codeCoverageIgnore } // if no database file is specified in the configuration, use a suitable default $dbFile = $dbFile ?? Arsse::$conf->dbSQLite3File ?? \JKingWeb\Arsse\BASE."arsse.db"; - $mode = \SQLITE3_OPEN_READWRITE | \SQLITE3_OPEN_CREATE; $timeout = Arsse::$conf->dbSQLite3Timeout * 1000; try { - $this->db = $this->makeConnection($dbFile, $mode, Arsse::$conf->dbSQLite3Key); - // enable exceptions - $this->db->enableExceptions(true); + $this->makeConnection($dbFile, Arsse::$conf->dbSQLite3Key); // set the timeout; parameters are not allowed for pragmas, but this usage should be safe $this->exec("PRAGMA busy_timeout = $timeout"); // set other initial options @@ -60,8 +57,14 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { } } - protected function makeConnection(string $file, int $opts, string $key) { - return new \SQLite3($file, $opts, $key); + public static function requirementsMet(): bool { + return class_exists("SQLite3"); + } + + protected function makeConnection(string $file, string $key) { + $this->db = new \SQLite3($file, \SQLITE3_OPEN_READWRITE | \SQLITE3_OPEN_CREATE, $key); + // enable exceptions + $this->db->enableExceptions(true); } public function __destruct() { @@ -72,60 +75,42 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { unset($this->db); } + /** @codeCoverageIgnore */ + public static function create(): \JKingWeb\Arsse\Db\Driver { + if (self::requirementsMet()) { + return new self; + } elseif (PDODriver::requirementsMet()) { + return new PDODriver; + } else { + throw new Exception("extMissing", self::driverName()); + } + } + public static function driverName(): string { return Arsse::$lang->msg("Driver.Db.SQLite3.Name"); } + public static function schemaID(): string { + return "SQLite3"; + } + public function schemaVersion(): int { - return $this->query("PRAGMA user_version")->getValue(); + return (int) $this->query("PRAGMA user_version")->getValue(); } public function schemaUpdate(int $to, string $basePath = null): bool { - $ver = $this->schemaVersion(); - if (!Arsse::$conf->dbAutoUpdate) { - throw new Exception("updateManual", ['version' => $ver, 'driver_name' => $this->driverName()]); - } elseif ($ver >= $to) { - throw new Exception("updateTooNew", ['difference' => ($ver - $to), 'current' => $ver, 'target' => $to, 'driver_name' => $this->driverName()]); - } - $sep = \DIRECTORY_SEPARATOR; - $path = ($basePath ?? \JKingWeb\Arsse\BASE."sql").$sep."SQLite3".$sep; // turn off foreign keys $this->exec("PRAGMA foreign_keys = no"); - // lock the database - $this->savepointCreate(true); - for ($a = $this->schemaVersion(); $a < $to; $a++) { - $this->savepointCreate(); - try { - $file = $path.$a.".sql"; - if (!file_exists($file)) { - throw new Exception("updateFileMissing", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]); - } elseif (!is_readable($file)) { - throw new Exception("updateFileUnreadable", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]); - } - $sql = @file_get_contents($file); - if ($sql===false) { - throw new Exception("updateFileUnusable", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]); // @codeCoverageIgnore - } - try { - $this->exec($sql); - } catch (\Throwable $e) { - throw new Exception("updateFileError", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a, 'message' => $this->getError()]); - } - if ($this->schemaVersion() != $a+1) { - throw new Exception("updateFileIncomplete", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]); - } - } catch (\Throwable $e) { - // undo any partial changes from the failed update - $this->savepointUndo(); - // commit any successful updates if updating by more than one version - $this->savepointRelease(); - // throw the error received - throw $e; - } - $this->savepointRelease(); + // run the generic updater + try { + parent::schemaUpdate($to, $basePath); + } catch (\Throwable $e) { + // turn foreign keys back on + $this->exec("PRAGMA foreign_keys = yes"); + // pass the exception up + throw $e; } - $this->savepointRelease(); // turn foreign keys back on $this->exec("PRAGMA foreign_keys = yes"); return true; diff --git a/lib/Db/SQLite3/PDODriver.php b/lib/Db/SQLite3/PDODriver.php new file mode 100644 index 00000000..42e1cf83 --- /dev/null +++ b/lib/Db/SQLite3/PDODriver.php @@ -0,0 +1,46 @@ +db = new \PDO("sqlite:".$file, "", "", [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + } + + public function __destruct() { + unset($this->db); + } + + /** @codeCoverageIgnore */ + public static function create(): \JKingWeb\Arsse\Db\Driver { + if (self::requirementsMet()) { + return new self; + } elseif (Driver::requirementsMet()) { + return new Driver; + } else { + throw new Exception("extMissing", self::driverName()); + } + } + + + public static function driverName(): string { + return Arsse::$lang->msg("Driver.Db.SQLite3PDO.Name"); + } +} diff --git a/lib/Db/SQLite3/Statement.php b/lib/Db/SQLite3/Statement.php index 34228ef3..ab07b47b 100644 --- a/lib/Db/SQLite3/Statement.php +++ b/lib/Db/SQLite3/Statement.php @@ -17,11 +17,8 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { const SQLITE_CONSTRAINT = 19; const SQLITE_MISMATCH = 20; const BINDINGS = [ - "null" => \SQLITE3_NULL, "integer" => \SQLITE3_INTEGER, "float" => \SQLITE3_FLOAT, - "date" => \SQLITE3_TEXT, - "time" => \SQLITE3_TEXT, "datetime" => \SQLITE3_TEXT, "binary" => \SQLITE3_BLOB, "string" => \SQLITE3_TEXT, @@ -34,7 +31,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { public function __construct(\SQLite3 $db, \SQLite3Stmt $st, array $bindings = []) { $this->db = $db; $this->st = $st; - $this->rebindArray($bindings); + $this->retypeArray($bindings); } public function __destruct() { @@ -59,34 +56,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { return new Result($r, [$changes, $lastId], $this); } - protected function bindValues(array $values, int $offset = 0): int { - $a = $offset; - foreach ($values as $value) { - if (is_array($value)) { - // recursively flatten any arrays, which may be provided for SET or IN() clauses - $a += $this->bindValues($value, $a); - } elseif (array_key_exists($a, $this->types)) { - // if the parameter type is something other than the known values, this is an error - assert(array_key_exists($this->types[$a], self::BINDINGS), new Exception("paramTypeUnknown", $this->types[$a])); - // if the parameter type is null or the value is null (and the type is nullable), just bind null - if ($this->types[$a]=="null" || ($this->isNullable[$a] && is_null($value))) { - $this->st->bindValue($a+1, null, \SQLITE3_NULL); - } else { - // otherwise cast the value to the right type and bind the result - $type = self::BINDINGS[$this->types[$a]]; - $value = $this->cast($value, $this->types[$a], $this->isNullable[$a]); - // re-adjust for null casts - if ($value===null) { - $type = \SQLITE3_NULL; - } - // perform binding - $this->st->bindValue($a+1, $value, $type); - } - $a++; - } else { - throw new Exception("paramTypeMissing", $a+1); - } - } - return $a - $offset; + protected function bindValue($value, string $type, int $position): bool { + return $this->st->bindValue($position, $value, is_null($value) ? \SQLITE3_NULL : self::BINDINGS[$type]); } } diff --git a/lib/Db/Statement.php b/lib/Db/Statement.php index f95bdfbb..b59e075b 100644 --- a/lib/Db/Statement.php +++ b/lib/Db/Statement.php @@ -8,16 +8,12 @@ namespace JKingWeb\Arsse\Db; interface Statement { const TYPES = [ - "null" => "null", - "nil" => "null", "int" => "integer", "integer" => "integer", "float" => "float", "double" => "float", "real" => "float", "numeric" => "float", - "date" => "date", - "time" => "time", "datetime" => "datetime", "timestamp" => "datetime", "blob" => "binary", @@ -33,6 +29,6 @@ interface Statement { public function run(...$values): Result; public function runArray(array $values = []): Result; - public function rebind(...$bindings): bool; - public function rebindArray(array $bindings): bool; + public function retype(...$bindings): bool; + public function retypeArray(array $bindings): bool; } diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 814ee570..2b8fa5da 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -353,7 +353,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { $out = []; foreach ($feeds as $feed) { // since in our implementation feeds don't belong the users, the 'userId' field will always be an empty string - $out[] = ['id' => $feed, 'userId' => ""]; + $out[] = ['id' => (int) $feed, 'userId' => ""]; } return new Response(200, ['feeds' => $out]); } @@ -419,7 +419,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { $out[] = $this->feedTranslate($sub); } $out = ['feeds' => $out]; - $out['starredCount'] = Arsse::$db->articleStarred(Arsse::$user->id)['total']; + $out['starredCount'] = (int) Arsse::$db->articleStarred(Arsse::$user->id)['total']; $newest = Arsse::$db->editionLatest(Arsse::$user->id); if ($newest) { $out['newestItemId'] = $newest; diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 7435c80d..d3f7728b 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -231,7 +231,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // prepare data for each subscription; we also add unread counts for their host categories foreach (Arsse::$db->subscriptionList($user) as $f) { // add the feed to the list of feeds - $feeds[] = ['id' => (string) $f['id'], 'updated' => Date::transform($f['updated'], "iso8601", "sql"),'counter' => $f['unread'], 'has_img' => (int) (strlen((string) $f['favicon']) > 0)]; // ID is cast to string for consistency with TTRSS + $feeds[] = ['id' => (string) $f['id'], 'updated' => Date::transform($f['updated'], "iso8601", "sql"),'counter' => (int) $f['unread'], 'has_img' => (int) (strlen((string) $f['favicon']) > 0)]; // ID is cast to string for consistency with TTRSS // add the feed's unread count to the global unread count $countAll += $f['unread']; // add the feed's unread count to its category unread count @@ -242,7 +242,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // prepare data for each non-empty label foreach (Arsse::$db->labelList($user, false) as $l) { $unread = $l['articles'] - $l['read']; - $labels[] = ['id' => $this->labelOut($l['id']), 'counter' => $unread, 'auxcounter' => $l['articles']]; + $labels[] = ['id' => $this->labelOut($l['id']), 'counter' => $unread, 'auxcounter' => (int) $l['articles']]; $categories[$catmap[self::CAT_LABELS]]['counter'] += $unread; } // do a second pass on categories, summing descendant unread counts for ancestors @@ -266,14 +266,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } // do a third pass on categories, building a final category list; this is done so that the original sort order is retained foreach ($categories as $c) { - $cats[] = ['id' => $c['id'], 'kind' => "cat", 'counter' => $catCounts[$c['id']]]; + $cats[] = ['id' => (int) $c['id'], 'kind' => "cat", 'counter' => $catCounts[$c['id']]]; } // prepare data for the virtual feeds and other counters $special = [ ['id' => "global-unread", 'counter' => $countAll], //this should not count archived articles, but we do not have an archive ['id' => "subscribed-feeds", 'counter' => $countSubs], ['id' => self::FEED_ARCHIVED, 'counter' => 0, 'auxcounter' => 0], // Archived articles - ['id' => self::FEED_STARRED, 'counter' => $starred['unread'], 'auxcounter' => $starred['total']], // Starred articles + ['id' => self::FEED_STARRED, 'counter' => (int) $starred['unread'], 'auxcounter' => (int) $starred['total']], // Starred articles ['id' => self::FEED_PUBLISHED, 'counter' => 0, 'auxcounter' => 0], // Published articles ['id' => self::FEED_FRESH, 'counter' => $fresh, 'auxcounter' => 0], // Fresh articles ['id' => self::FEED_ALL, 'counter' => $countAll, 'auxcounter' => 0], // All articles @@ -323,7 +323,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'id' => "FEED:".self::FEED_STARRED, 'bare_id' => self::FEED_STARRED, 'icon' => "images/star.png", - 'unread' => Arsse::$db->articleStarred($user)['unread'], + 'unread' => (int) Arsse::$db->articleStarred($user)['unread'], ], $tSpecial), array_merge([ // Published articles 'name' => Arsse::$lang->msg("API.TTRSS.Feed.Published"), @@ -406,7 +406,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return ['categories' => ['identifier' => "id", 'label' => "name", 'items' => $out]]; } - protected function enumerateFeeds(array $subs, int $parent = null): array { + protected function enumerateFeeds(array $subs, $parent = null): array { $out = []; foreach ($subs as $s) { if ($s['folder'] != $parent) { @@ -415,7 +415,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $out[] = [ 'name' => $s['title'], 'id' => "FEED:".$s['id'], - 'bare_id' => $s['id'], + 'bare_id' => (int) $s['id'], 'icon' => $s['favicon'] ? "feed-icons/".$s['id'].".ico" : false, 'error' => (string) $s['err_msg'], 'param' => Date::transform($s['updated'], "iso8601", "sql"), @@ -428,7 +428,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return $out; } - protected function enumerateCategories(array $cats, array $subs, int $parent = null, bool $all = false): array { + protected function enumerateCategories(array $cats, array $subs, $parent = null, bool $all = false): array { $out = []; $feedTotal = 0; foreach ($cats as $c) { @@ -442,8 +442,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $out[] = [ 'name' => $c['name'], 'id' => "CAT:".$c['id'], - 'bare_id' => $c['id'], - 'parent_id' => $c['parent'], // top-level categories are not supposed to have this property; we deviated and have the property set to null because it's simpler that way + 'bare_id' => (int) $c['id'], + 'parent_id' => (int) $c['parent'] ?: null, // top-level categories are not supposed to have this property; we deviated and have the property set to null because it's simpler that way 'type' => "category", 'auxcounter' => 0, 'unread' => 0, @@ -714,13 +714,13 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // NOTE: the list is a flat one: it includes children, but not other descendents foreach (Arsse::$db->folderList($user, $cat, false) as $c) { // get the number of unread for the category and its descendents; those with zero unread are excluded in "unread-only" mode - $count = Arsse::$db->articleCount($user, (new Context)->unread(true)->folder($c['id'])); + $count = Arsse::$db->articleCount($user, (new Context)->unread(true)->folder((int) $c['id'])); if (!$unread || $count) { $out[] = [ - 'id' => $c['id'], - 'title' => $c['name'], - 'unread' => $count, - 'is_cat' => true, + 'id' => (int) $c['id'], + 'title' => $c['name'], + 'unread' => (int) $count, + 'is_cat' => true, 'order_id' => ++$order, ]; } @@ -764,9 +764,9 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } // otherwise, append the subscription $out[] = [ - 'id' => $s['id'], + 'id' => (int) $s['id'], 'title' => $s['title'], - 'unread' => $s['unread'], + 'unread' => (int) $s['unread'], 'cat_id' => (int) $s['folder'], 'feed_url' => $s['url'], 'has_icon' => (bool) $s['favicon'], @@ -920,8 +920,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return (abs($id) - self::LABEL_OFFSET); } - protected function labelOut(int $id): int { - return ($id * -1 - self::LABEL_OFFSET); + protected function labelOut($id): int { + return ((int) $id * -1 - self::LABEL_OFFSET); } public function opGetLabels(array $data): array { @@ -1194,12 +1194,12 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return $out; } - protected function articleLabelList(array $labels, int $id): array { + protected function articleLabelList(array $labels, $id): array { $out = []; if (!$labels) { return $out; } - foreach (Arsse::$db->articleLabelsGet(Arsse::$user->id, $id) as $label) { + foreach (Arsse::$db->articleLabelsGet(Arsse::$user->id, (int) $id) as $label) { $out[] = [ $this->labelOut($label), // ID $labels[$label], // name @@ -1224,7 +1224,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $out = []; try { foreach ($this->fetchArticles($data, Database::LIST_MINIMAL) as $row) { - $out[] = ['id' => $row['id']]; + $out[] = ['id' => (int) $row['id']]; } } catch (ExceptionInput $e) { // ignore database errors (feeds/categories that don't exist) @@ -1246,7 +1246,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { try { foreach ($this->fetchArticles($data, Database::LIST_FULL) as $article) { $row = [ - 'id' => $article['id'], + 'id' => (int) $article['id'], 'guid' => $article['guid'] ? "SHA256:".$article['guid'] : "", 'title' => $article['title'], 'link' => $article['url'], @@ -1313,9 +1313,9 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // wrap the output with (but after) the header $out = [ [ - 'id' => $data['feed_id'], + 'id' => (int) $data['feed_id'], 'is_cat' => $data['is_cat'] ?? false, - 'first_id' => $firstID, + 'first_id' => (int) $firstID, ], $out, ]; diff --git a/locale/en.php b/locale/en.php index e2848473..a6d16987 100644 --- a/locale/en.php +++ b/locale/en.php @@ -16,6 +16,7 @@ return [ 'API.TTRSS.FeedCount' => '{0, select, 1 {(1 feed)} other {({0} feeds)}}', 'Driver.Db.SQLite3.Name' => 'SQLite 3', + 'Driver.Db.SQLite3PDO.Name' => 'SQLite 3 (PDO)', 'Driver.Service.Curl.Name' => 'HTTP (curl)', 'Driver.Service.Internal.Name' => 'Internal', 'Driver.User.Internal.Name' => 'Internal', diff --git a/sql/SQLite3/1.sql b/sql/SQLite3/1.sql index fe369420..8f273e60 100644 --- a/sql/SQLite3/1.sql +++ b/sql/SQLite3/1.sql @@ -45,4 +45,4 @@ drop table arsse_marks_old; -- set version marker pragma user_version = 2; -update arsse_meta set value = '2' where key is 'schema_version'; \ No newline at end of file +update arsse_meta set value = '2' where key = 'schema_version'; \ No newline at end of file diff --git a/sql/SQLite3/2.sql b/sql/SQLite3/2.sql index 12a269ad..87f21efe 100644 --- a/sql/SQLite3/2.sql +++ b/sql/SQLite3/2.sql @@ -108,4 +108,4 @@ drop table arsse_labels_old; -- set version marker pragma user_version = 3; -update arsse_meta set value = '3' where key is 'schema_version'; \ No newline at end of file +update arsse_meta set value = '3' where key = 'schema_version'; \ No newline at end of file diff --git a/tests/cases/Db/SQLite3/TestCreation.php b/tests/cases/Db/SQLite3/TestCreation.php index f4fb34a2..4f04918f 100644 --- a/tests/cases/Db/SQLite3/TestCreation.php +++ b/tests/cases/Db/SQLite3/TestCreation.php @@ -21,7 +21,7 @@ class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest { protected $ch; public function setUp() { - if (!extension_loaded("sqlite3")) { + if (!Driver::requirementsMet()) { $this->markTestSkipped("SQLite extension not loaded"); } $this->clearData(); @@ -109,7 +109,6 @@ class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest { // set up configuration Arsse::$conf = new Conf(); Arsse::$conf->dbSQLite3File = ":memory:"; - // set up database shim } public function tearDown() { diff --git a/tests/cases/Db/SQLite3/TestDriver.php b/tests/cases/Db/SQLite3/TestDriver.php index e611b7f4..64533268 100644 --- a/tests/cases/Db/SQLite3/TestDriver.php +++ b/tests/cases/Db/SQLite3/TestDriver.php @@ -10,8 +10,8 @@ use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Conf; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Db\SQLite3\Driver; -use JKingWeb\Arsse\Db\SQLite3\Result; -use JKingWeb\Arsse\Db\SQLite3\Statement; +use JKingWeb\Arsse\Db\Result; +use JKingWeb\Arsse\Db\Statement; /** * @covers \JKingWeb\Arsse\Db\SQLite3\Driver @@ -22,7 +22,7 @@ class TestDriver extends \JKingWeb\Arsse\Test\AbstractTest { protected $ch; public function setUp() { - if (!extension_loaded("sqlite3")) { + if (!Driver::requirementsMet()) { $this->markTestSkipped("SQLite extension not loaded"); } $this->clearData(); diff --git a/tests/cases/Db/SQLite3/TestResult.php b/tests/cases/Db/SQLite3/TestResult.php index 91ffbfe0..7c302a6b 100644 --- a/tests/cases/Db/SQLite3/TestResult.php +++ b/tests/cases/Db/SQLite3/TestResult.php @@ -13,9 +13,10 @@ class TestResult extends \JKingWeb\Arsse\Test\AbstractTest { protected $c; public function setUp() { - if (!extension_loaded("sqlite3")) { + if (!\JKingWeb\Arsse\Db\SQLite3\Driver::requirementsMet()) { $this->markTestSkipped("SQLite extension not loaded"); } + $this->clearData(); $c = new \SQLite3(":memory:"); $c->enableExceptions(true); $this->c = $c; @@ -24,6 +25,7 @@ class TestResult extends \JKingWeb\Arsse\Test\AbstractTest { public function tearDown() { $this->c->close(); unset($this->c); + $this->clearData(); } public function testConstructResult() { diff --git a/tests/cases/Db/SQLite3/TestStatement.php b/tests/cases/Db/SQLite3/TestStatement.php index 7e722c2d..5a195a84 100644 --- a/tests/cases/Db/SQLite3/TestStatement.php +++ b/tests/cases/Db/SQLite3/TestStatement.php @@ -12,16 +12,14 @@ use JKingWeb\Arsse\Db\Statement; * @covers \JKingWeb\Arsse\Db\SQLite3\Statement * @covers \JKingWeb\Arsse\Db\SQLite3\ExceptionBuilder */ class TestStatement extends \JKingWeb\Arsse\Test\AbstractTest { - use \JKingWeb\Arsse\Test\Db\BindingTests; - protected $c; protected static $imp = \JKingWeb\Arsse\Db\SQLite3\Statement::class; public function setUp() { - $this->clearData(); - if (!extension_loaded("sqlite3")) { + if (!\JKingWeb\Arsse\Db\SQLite3\Driver::requirementsMet()) { $this->markTestSkipped("SQLite extension not loaded"); } + $this->clearData(); $c = new \SQLite3(":memory:"); $c->enableExceptions(true); $this->c = $c; @@ -37,15 +35,222 @@ class TestStatement extends \JKingWeb\Arsse\Test\AbstractTest { $s = new self::$imp($this->c, $nativeStatement); $types = array_unique(Statement::TYPES); foreach ($types as $type) { - $s->rebindArray([$strict ? "strict $type" : $type]); + $s->retypeArray([$strict ? "strict $type" : $type]); $val = $s->runArray([$input])->getRow()['value']; $this->assertSame($expectations[$type], $val, "Binding from type $type failed comparison."); - $s->rebind(...[$strict ? "strict $type" : $type]); + $s->retype(...[$strict ? "strict $type" : $type]); $val = $s->run(...[$input])->getRow()['value']; $this->assertSame($expectations[$type], $val, "Binding from type $type failed comparison."); } } + /** @dataProvider provideBindings */ + public function testBindATypedValue($value, $type, $exp) { + $typeStr = "'".str_replace("'", "''", $type)."'"; + $nativeStatement = $this->c->prepare( + "SELECT ( + (CASE WHEN substr($typeStr, 0, 7) <> 'strict ' then null else 1 end) is null + and ? is null + ) or ( + $exp = ? + ) as pass" + ); + $s = new self::$imp($this->c, $nativeStatement); + $s->retypeArray([$type, $type]); + $act = (bool) $s->run(...[$value, $value])->getRow()['pass']; + $this->assertTrue($act); + } + + public function provideBindings() { + $dateMutable = new \DateTime("Noon Today", new \DateTimezone("America/Toronto")); + $dateImmutable = new \DateTimeImmutable("Noon Today", new \DateTimezone("America/Toronto")); + $dateUTC = new \DateTime("@".$dateMutable->getTimestamp(), new \DateTimezone("UTC")); + return [ + /* input, type, expected binding as SQL fragment */ + [null, "integer", "null"], + [null, "float", "null"], + [null, "string", "null"], + [null, "binary", "null"], + [null, "datetime", "null"], + [null, "boolean", "null"], + [null, "strict integer", "0"], + [null, "strict float", "0.0"], + [null, "strict string", "''"], + [null, "strict binary", "x''"], + [null, "strict datetime", "'1970-01-01 00:00:00'"], + [null, "strict boolean", "0"], + // true + [true, "integer", "1"], + [true, "float", "1.0"], + [true, "string", "'1'"], + [true, "binary", "x'31'"], + [true, "datetime", "null"], + [true, "boolean", "1"], + [true, "strict integer", "1"], + [true, "strict float", "1.0"], + [true, "strict string", "'1'"], + [true, "strict binary", "x'31'"], + [true, "strict datetime", "'1970-01-01 00:00:00'"], + [true, "strict boolean", "1"], + // false + [false, "integer", "0"], + [false, "float", "0.0"], + [false, "string", "''"], + [false, "binary", "x''"], + [false, "datetime", "null"], + [false, "boolean", "0"], + [false, "strict integer", "0"], + [false, "strict float", "0.0"], + [false, "strict string", "''"], + [false, "strict binary", "x''"], + [false, "strict datetime", "'1970-01-01 00:00:00'"], + [false, "strict boolean", "0"], + // integer + [2112, "integer", "2112"], + [2112, "float", "2112.0"], + [2112, "string", "'2112'"], + [2112, "binary", "x'32313132'"], + [2112, "datetime", "'1970-01-01 00:35:12'"], + [2112, "boolean", "1"], + [2112, "strict integer", "2112"], + [2112, "strict float", "2112.0"], + [2112, "strict string", "'2112'"], + [2112, "strict binary", "x'32313132'"], + [2112, "strict datetime", "'1970-01-01 00:35:12'"], + [2112, "strict boolean", "1"], + // integer zero + [0, "integer", "0"], + [0, "float", "0.0"], + [0, "string", "'0'"], + [0, "binary", "x'30'"], + [0, "datetime", "'1970-01-01 00:00:00'"], + [0, "boolean", "0"], + [0, "strict integer", "0"], + [0, "strict float", "0.0"], + [0, "strict string", "'0'"], + [0, "strict binary", "x'30'"], + [0, "strict datetime", "'1970-01-01 00:00:00'"], + [0, "strict boolean", "0"], + // float + [2112.99, "integer", "2112"], + [2112.99, "float", "2112.99"], + [2112.99, "string", "'2112.99'"], + [2112.99, "binary", "x'323131322e3939'"], + [2112.99, "datetime", "'1970-01-01 00:35:12'"], + [2112.99, "boolean", "1"], + [2112.99, "strict integer", "2112"], + [2112.99, "strict float", "2112.99"], + [2112.99, "strict string", "'2112.99'"], + [2112.99, "strict binary", "x'323131322e3939'"], + [2112.99, "strict datetime", "'1970-01-01 00:35:12'"], + [2112.99, "strict boolean", "1"], + // float zero + [0.0, "integer", "0"], + [0.0, "float", "0.0"], + [0.0, "string", "'0'"], + [0.0, "binary", "x'30'"], + [0.0, "datetime", "'1970-01-01 00:00:00'"], + [0.0, "boolean", "0"], + [0.0, "strict integer", "0"], + [0.0, "strict float", "0.0"], + [0.0, "strict string", "'0'"], + [0.0, "strict binary", "x'30'"], + [0.0, "strict datetime", "'1970-01-01 00:00:00'"], + [0.0, "strict boolean", "0"], + // ASCII string + ["Random string", "integer", "0"], + ["Random string", "float", "0.0"], + ["Random string", "string", "'Random string'"], + ["Random string", "binary", "x'52616e646f6d20737472696e67'"], + ["Random string", "datetime", "null"], + ["Random string", "boolean", "1"], + ["Random string", "strict integer", "0"], + ["Random string", "strict float", "0.0"], + ["Random string", "strict string", "'Random string'"], + ["Random string", "strict binary", "x'52616e646f6d20737472696e67'"], + ["Random string", "strict datetime", "'1970-01-01 00:00:00'"], + ["Random string", "strict boolean", "1"], + // UTF-8 string + ["é", "integer", "0"], + ["é", "float", "0.0"], + ["é", "string", "char(233)"], + ["é", "binary", "x'c3a9'"], + ["é", "datetime", "null"], + ["é", "boolean", "1"], + ["é", "strict integer", "0"], + ["é", "strict float", "0.0"], + ["é", "strict string", "char(233)"], + ["é", "strict binary", "x'c3a9'"], + ["é", "strict datetime", "'1970-01-01 00:00:00'"], + ["é", "strict boolean", "1"], + // binary string + [chr(233).chr(233), "integer", "0"], + [chr(233).chr(233), "float", "0.0"], + [chr(233).chr(233), "string", "'".chr(233).chr(233)."'"], + [chr(233).chr(233), "binary", "x'e9e9'"], + [chr(233).chr(233), "datetime", "null"], + [chr(233).chr(233), "boolean", "1"], + [chr(233).chr(233), "strict integer", "0"], + [chr(233).chr(233), "strict float", "0.0"], + [chr(233).chr(233), "strict string", "'".chr(233).chr(233)."'"], + [chr(233).chr(233), "strict binary", "x'e9e9'"], + [chr(233).chr(233), "strict datetime", "'1970-01-01 00:00:00'"], + [chr(233).chr(233), "strict boolean", "1"], + // ISO 8601 date string + ["2017-01-09T13:11:17", "integer", "0"], + ["2017-01-09T13:11:17", "float", "0.0"], + ["2017-01-09T13:11:17", "string", "'2017-01-09T13:11:17'"], + ["2017-01-09T13:11:17", "binary", "x'323031372d30312d30395431333a31313a3137'"], + ["2017-01-09T13:11:17", "datetime", "'2017-01-09 13:11:17'"], + ["2017-01-09T13:11:17", "boolean", "1"], + ["2017-01-09T13:11:17", "strict integer", "0"], + ["2017-01-09T13:11:17", "strict float", "0.0"], + ["2017-01-09T13:11:17", "strict string", "'2017-01-09T13:11:17'"], + ["2017-01-09T13:11:17", "strict binary", "x'323031372d30312d30395431333a31313a3137'"], + ["2017-01-09T13:11:17", "strict datetime", "'2017-01-09 13:11:17'"], + ["2017-01-09T13:11:17", "strict boolean", "1"], + // arbitrary date string + ["Today", "integer", "0"], + ["Today", "float", "0.0"], + ["Today", "string", "'Today'"], + ["Today", "binary", "x'546f646179'"], + ["Today", "datetime", "'".date_create("Today", new \DateTimezone("UTC"))->format("Y-m-d H:i:s")."'"], + ["Today", "boolean", "1"], + ["Today", "strict integer", "0"], + ["Today", "strict float", "0.0"], + ["Today", "strict string", "'Today'"], + ["Today", "strict binary", "x'546f646179'"], + ["Today", "strict datetime", "'".date_create("Today", new \DateTimezone("UTC"))->format("Y-m-d H:i:s")."'"], + ["Today", "strict boolean", "1"], + // mutable date object + [$dateMutable, "integer", $dateUTC->getTimestamp()], + [$dateMutable, "float", $dateUTC->getTimestamp().".0"], + [$dateMutable, "string", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + [$dateMutable, "binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"], + [$dateMutable, "datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + [$dateMutable, "boolean", "1"], + [$dateMutable, "strict integer", $dateUTC->getTimestamp()], + [$dateMutable, "strict float", $dateUTC->getTimestamp().".0"], + [$dateMutable, "strict string", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + [$dateMutable, "strict binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"], + [$dateMutable, "strict datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + [$dateMutable, "strict boolean", "1"], + // immutable date object + [$dateImmutable, "integer", $dateUTC->getTimestamp()], + [$dateImmutable, "float", $dateUTC->getTimestamp().".0"], + [$dateImmutable, "string", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + [$dateImmutable, "binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"], + [$dateImmutable, "datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + [$dateImmutable, "boolean", "1"], + [$dateImmutable, "strict integer", $dateUTC->getTimestamp()], + [$dateImmutable, "strict float", $dateUTC->getTimestamp().".0"], + [$dateImmutable, "strict string", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + [$dateImmutable, "strict binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"], + [$dateImmutable, "strict datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + [$dateImmutable, "strict boolean", "1"], + ]; + } + public function testConstructStatement() { $nativeStatement = $this->c->prepare("SELECT ? as value"); $this->assertInstanceOf(Statement::class, new \JKingWeb\Arsse\Db\SQLite3\Statement($this->c, $nativeStatement)); diff --git a/tests/cases/Db/SQLite3/TestUpdate.php b/tests/cases/Db/SQLite3/TestUpdate.php index 1e485d56..d6c8fc3c 100644 --- a/tests/cases/Db/SQLite3/TestUpdate.php +++ b/tests/cases/Db/SQLite3/TestUpdate.php @@ -26,7 +26,7 @@ class TestUpdate extends \JKingWeb\Arsse\Test\AbstractTest { const MINIMAL2 = "pragma user_version=2"; public function setUp(Conf $conf = null) { - if (!extension_loaded("sqlite3")) { + if (!Driver::requirementsMet()) { $this->markTestSkipped("SQLite extension not loaded"); } $this->clearData(); @@ -73,6 +73,12 @@ class TestUpdate extends \JKingWeb\Arsse\Test\AbstractTest { $this->drv->schemaUpdate(1, $this->base); } + public function testLoadEmptyFile() { + file_put_contents($this->path."0.sql", ""); + $this->assertException("updateFileIncomplete", "Db"); + $this->drv->schemaUpdate(1, $this->base); + } + public function testLoadCorrectFile() { file_put_contents($this->path."0.sql", self::MINIMAL1); $this->drv->schemaUpdate(1, $this->base); @@ -81,7 +87,7 @@ class TestUpdate extends \JKingWeb\Arsse\Test\AbstractTest { public function testPerformPartialUpdate() { file_put_contents($this->path."0.sql", self::MINIMAL1); - file_put_contents($this->path."1.sql", ""); + file_put_contents($this->path."1.sql", " "); $this->assertException("updateFileIncomplete", "Db"); try { $this->drv->schemaUpdate(2, $this->base); diff --git a/tests/cases/Db/SQLite3PDO/Database/TestArticle.php b/tests/cases/Db/SQLite3PDO/Database/TestArticle.php new file mode 100644 index 00000000..ac63498c --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/Database/TestArticle.php @@ -0,0 +1,14 @@ + */ +class TestArticle extends \JKingWeb\Arsse\Test\AbstractTest { + use \JKingWeb\Arsse\Test\Database\Setup; + use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO; + use \JKingWeb\Arsse\Test\Database\SeriesArticle; +} diff --git a/tests/cases/Db/SQLite3PDO/Database/TestCleanup.php b/tests/cases/Db/SQLite3PDO/Database/TestCleanup.php new file mode 100644 index 00000000..76e45308 --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/Database/TestCleanup.php @@ -0,0 +1,14 @@ + */ +class TestCleanup extends \JKingWeb\Arsse\Test\AbstractTest { + use \JKingWeb\Arsse\Test\Database\Setup; + use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO; + use \JKingWeb\Arsse\Test\Database\SeriesCleanup; +} diff --git a/tests/cases/Db/SQLite3PDO/Database/TestFeed.php b/tests/cases/Db/SQLite3PDO/Database/TestFeed.php new file mode 100644 index 00000000..9ffc733e --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/Database/TestFeed.php @@ -0,0 +1,14 @@ + */ +class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest { + use \JKingWeb\Arsse\Test\Database\Setup; + use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO; + use \JKingWeb\Arsse\Test\Database\SeriesFeed; +} diff --git a/tests/cases/Db/SQLite3PDO/Database/TestFolder.php b/tests/cases/Db/SQLite3PDO/Database/TestFolder.php new file mode 100644 index 00000000..30cff605 --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/Database/TestFolder.php @@ -0,0 +1,14 @@ + */ +class TestFolder extends \JKingWeb\Arsse\Test\AbstractTest { + use \JKingWeb\Arsse\Test\Database\Setup; + use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO; + use \JKingWeb\Arsse\Test\Database\SeriesFolder; +} diff --git a/tests/cases/Db/SQLite3PDO/Database/TestLabel.php b/tests/cases/Db/SQLite3PDO/Database/TestLabel.php new file mode 100644 index 00000000..38bd1238 --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/Database/TestLabel.php @@ -0,0 +1,10 @@ + */ +class TestLabel extends \JKingWeb\Arsse\Test\AbstractTest { + use \JKingWeb\Arsse\Test\Database\Setup; + use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO; + use \JKingWeb\Arsse\Test\Database\SeriesLabel; +} diff --git a/tests/cases/Db/SQLite3PDO/Database/TestMeta.php b/tests/cases/Db/SQLite3PDO/Database/TestMeta.php new file mode 100644 index 00000000..7d3e7ed7 --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/Database/TestMeta.php @@ -0,0 +1,14 @@ + */ +class TestMeta extends \JKingWeb\Arsse\Test\AbstractTest { + use \JKingWeb\Arsse\Test\Database\Setup; + use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO; + use \JKingWeb\Arsse\Test\Database\SeriesMeta; +} diff --git a/tests/cases/Db/SQLite3PDO/Database/TestMiscellany.php b/tests/cases/Db/SQLite3PDO/Database/TestMiscellany.php new file mode 100644 index 00000000..cc8ca608 --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/Database/TestMiscellany.php @@ -0,0 +1,14 @@ + */ +class TestMiscellany extends \JKingWeb\Arsse\Test\AbstractTest { + use \JKingWeb\Arsse\Test\Database\Setup; + use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO; + use \JKingWeb\Arsse\Test\Database\SeriesMiscellany; +} diff --git a/tests/cases/Db/SQLite3PDO/Database/TestSession.php b/tests/cases/Db/SQLite3PDO/Database/TestSession.php new file mode 100644 index 00000000..dbc71fb1 --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/Database/TestSession.php @@ -0,0 +1,10 @@ + */ +class TestSession extends \JKingWeb\Arsse\Test\AbstractTest { + use \JKingWeb\Arsse\Test\Database\Setup; + use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO; + use \JKingWeb\Arsse\Test\Database\SeriesSession; +} diff --git a/tests/cases/Db/SQLite3PDO/Database/TestSubscription.php b/tests/cases/Db/SQLite3PDO/Database/TestSubscription.php new file mode 100644 index 00000000..0205b48e --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/Database/TestSubscription.php @@ -0,0 +1,14 @@ + */ +class TestSubscription extends \JKingWeb\Arsse\Test\AbstractTest { + use \JKingWeb\Arsse\Test\Database\Setup; + use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO; + use \JKingWeb\Arsse\Test\Database\SeriesSubscription; +} diff --git a/tests/cases/Db/SQLite3PDO/Database/TestUser.php b/tests/cases/Db/SQLite3PDO/Database/TestUser.php new file mode 100644 index 00000000..b77822dd --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/Database/TestUser.php @@ -0,0 +1,14 @@ + */ +class TestUser extends \JKingWeb\Arsse\Test\AbstractTest { + use \JKingWeb\Arsse\Test\Database\Setup; + use \JKingWeb\Arsse\Test\Database\DriverSQLite3PDO; + use \JKingWeb\Arsse\Test\Database\SeriesUser; +} diff --git a/tests/cases/Db/SQLite3PDO/TestCreation.php b/tests/cases/Db/SQLite3PDO/TestCreation.php new file mode 100644 index 00000000..34cb8248 --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/TestCreation.php @@ -0,0 +1,196 @@ + + * @covers \JKingWeb\Arsse\Db\PDODriver + * @covers \JKingWeb\Arsse\Db\PDOError */ +class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest { + protected $data; + protected $drv; + protected $ch; + + public function setUp() { + if (!Driver::requirementsMet()) { + $this->markTestSkipped("PDO-SQLite extension not loaded"); + } + $this->clearData(); + // test files + $this->files = [ + // cannot create files + 'Cmain' => [], + 'Cshm' => [ + 'arsse.db' => "", + 'arsse.db-wal' => "", + ], + 'Cwal' => [ + 'arsse.db' => "", + ], + // cannot write to files + 'Wmain' => [ + 'arsse.db' => "", + 'arsse.db-wal' => "", + 'arsse.db-shm' => "", + ], + 'Wwal' => [ + 'arsse.db' => "", + 'arsse.db-wal' => "", + 'arsse.db-shm' => "", + ], + 'Wshm' => [ + 'arsse.db' => "", + 'arsse.db-wal' => "", + 'arsse.db-shm' => "", + ], + // cannot read from files + 'Rmain' => [ + 'arsse.db' => "", + 'arsse.db-wal' => "", + 'arsse.db-shm' => "", + ], + 'Rwal' => [ + 'arsse.db' => "", + 'arsse.db-wal' => "", + 'arsse.db-shm' => "", + ], + 'Rshm' => [ + 'arsse.db' => "", + 'arsse.db-wal' => "", + 'arsse.db-shm' => "", + ], + // can neither read from or write to files + 'Amain' => [ + 'arsse.db' => "", + 'arsse.db-wal' => "", + 'arsse.db-shm' => "", + ], + 'Awal' => [ + 'arsse.db' => "", + 'arsse.db-wal' => "", + 'arsse.db-shm' => "", + ], + 'Ashm' => [ + 'arsse.db' => "", + 'arsse.db-wal' => "", + 'arsse.db-shm' => "", + ], + // non-filesystem errors + 'corrupt' => [ + 'arsse.db' => "", + 'arsse.db-wal' => "", + 'arsse.db-shm' => "", + ], + ]; + $vfs = vfsStream::setup("dbtest", 0777, $this->files); + $this->path = $path = $vfs->url()."/"; + // set up access blocks + chmod($path."Cmain", 0555); + chmod($path."Cwal", 0555); + chmod($path."Cshm", 0555); + chmod($path."Rmain/arsse.db", 0333); + chmod($path."Rwal/arsse.db-wal", 0333); + chmod($path."Rshm/arsse.db-shm", 0333); + chmod($path."Wmain/arsse.db", 0555); + chmod($path."Wwal/arsse.db-wal", 0555); + chmod($path."Wshm/arsse.db-shm", 0555); + chmod($path."Amain/arsse.db", 0111); + chmod($path."Awal/arsse.db-wal", 0111); + chmod($path."Ashm/arsse.db-shm", 0111); + // set up configuration + Arsse::$conf = new Conf(); + Arsse::$conf->dbSQLite3File = ":memory:"; + } + + public function tearDown() { + $this->clearData(); + } + + public function testFailToCreateDatabase() { + Arsse::$conf->dbSQLite3File = $this->path."Cmain/arsse.db"; + $this->assertException("fileUncreatable", "Db"); + new Driver; + } + + public function testFailToCreateJournal() { + Arsse::$conf->dbSQLite3File = $this->path."Cwal/arsse.db"; + $this->assertException("fileUncreatable", "Db"); + new Driver; + } + + public function testFailToCreateSharedMmeory() { + Arsse::$conf->dbSQLite3File = $this->path."Cshm/arsse.db"; + $this->assertException("fileUncreatable", "Db"); + new Driver; + } + + public function testFailToReadDatabase() { + Arsse::$conf->dbSQLite3File = $this->path."Rmain/arsse.db"; + $this->assertException("fileUnreadable", "Db"); + new Driver; + } + + public function testFailToReadJournal() { + Arsse::$conf->dbSQLite3File = $this->path."Rwal/arsse.db"; + $this->assertException("fileUnreadable", "Db"); + new Driver; + } + + public function testFailToReadSharedMmeory() { + Arsse::$conf->dbSQLite3File = $this->path."Rshm/arsse.db"; + $this->assertException("fileUnreadable", "Db"); + new Driver; + } + + public function testFailToWriteToDatabase() { + Arsse::$conf->dbSQLite3File = $this->path."Wmain/arsse.db"; + $this->assertException("fileUnwritable", "Db"); + new Driver; + } + + public function testFailToWriteToJournal() { + Arsse::$conf->dbSQLite3File = $this->path."Wwal/arsse.db"; + $this->assertException("fileUnwritable", "Db"); + new Driver; + } + + public function testFailToWriteToSharedMmeory() { + Arsse::$conf->dbSQLite3File = $this->path."Wshm/arsse.db"; + $this->assertException("fileUnwritable", "Db"); + new Driver; + } + + public function testFailToAccessDatabase() { + Arsse::$conf->dbSQLite3File = $this->path."Amain/arsse.db"; + $this->assertException("fileUnusable", "Db"); + new Driver; + } + + public function testFailToAccessJournal() { + Arsse::$conf->dbSQLite3File = $this->path."Awal/arsse.db"; + $this->assertException("fileUnusable", "Db"); + new Driver; + } + + public function testFailToAccessSharedMmeory() { + Arsse::$conf->dbSQLite3File = $this->path."Ashm/arsse.db"; + $this->assertException("fileUnusable", "Db"); + new Driver; + } + + public function testAssumeDatabaseCorruption() { + Arsse::$conf->dbSQLite3File = $this->path."corrupt/arsse.db"; + $this->assertException("fileCorrupt", "Db"); + new Driver; + } +} diff --git a/tests/cases/Db/SQLite3PDO/TestDriver.php b/tests/cases/Db/SQLite3PDO/TestDriver.php new file mode 100644 index 00000000..1fec09cc --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/TestDriver.php @@ -0,0 +1,344 @@ + + * @covers \JKingWeb\Arsse\Db\PDODriver + * @covers \JKingWeb\Arsse\Db\PDOError */ +class TestDriver extends \JKingWeb\Arsse\Test\AbstractTest { + protected $data; + protected $drv; + protected $ch; + + public function setUp() { + if (!PDODriver::requirementsMet()) { + $this->markTestSkipped("PDO-SQLite extension not loaded"); + } + $this->clearData(); + $conf = new Conf(); + Arsse::$conf = $conf; + $conf->dbDriver = PDODriver::class; + $conf->dbSQLite3Timeout = 0; + $conf->dbSQLite3File = tempnam(sys_get_temp_dir(), 'ook'); + $this->drv = new PDODriver(); + $this->ch = new \PDO("sqlite:".Arsse::$conf->dbSQLite3File, "", "", [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + } + + public function tearDown() { + unset($this->drv); + unset($this->ch); + if (isset(Arsse::$conf)) { + unlink(Arsse::$conf->dbSQLite3File); + } + $this->clearData(); + } + + public function testFetchDriverName() { + $class = Arsse::$conf->dbDriver; + $this->assertTrue(strlen($class::driverName()) > 0); + } + + public function testCheckCharacterSetAcceptability() { + $this->assertTrue($this->drv->charsetAcceptable()); + } + + public function testExecAValidStatement() { + $this->assertTrue($this->drv->exec("CREATE TABLE test(id integer primary key)")); + } + + public function testExecAnInvalidStatement() { + $this->assertException("engineErrorGeneral", "Db"); + $this->drv->exec("And the meek shall inherit the earth..."); + } + + public function testExecMultipleStatements() { + $this->assertTrue($this->drv->exec("CREATE TABLE test(id integer primary key); INSERT INTO test(id) values(2112)")); + $this->assertEquals(2112, $this->ch->query("SELECT id from test")->fetchColumn()); + } + + public function testExecTimeout() { + $this->ch->exec("BEGIN EXCLUSIVE TRANSACTION"); + $this->assertException("general", "Db", "ExceptionTimeout"); + $this->drv->exec("CREATE TABLE test(id integer primary key)"); + } + + public function testExecConstraintViolation() { + $this->drv->exec("CREATE TABLE test(id integer not null)"); + $this->assertException("constraintViolation", "Db", "ExceptionInput"); + $this->drv->exec("INSERT INTO test(id) values(null)"); + } + + public function testExecTypeViolation() { + $this->drv->exec("CREATE TABLE test(id integer primary key)"); + $this->assertException("typeViolation", "Db", "ExceptionInput"); + $this->drv->exec("INSERT INTO test(id) values('ook')"); + } + + public function testMakeAValidQuery() { + $this->assertInstanceOf(Result::class, $this->drv->query("SELECT 1")); + } + + public function testMakeAnInvalidQuery() { + $this->assertException("engineErrorGeneral", "Db"); + $this->drv->query("Apollo was astonished; Dionysus thought me mad"); + } + + public function testQueryTimeout() { + $this->ch->exec("BEGIN EXCLUSIVE TRANSACTION"); + $this->assertException("general", "Db", "ExceptionTimeout"); + $this->drv->query("CREATE TABLE test(id integer primary key)"); + } + + public function testQueryConstraintViolation() { + $this->drv->exec("CREATE TABLE test(id integer not null)"); + $this->assertException("constraintViolation", "Db", "ExceptionInput"); + $this->drv->query("INSERT INTO test(id) values(null)"); + } + + public function testQueryTypeViolation() { + $this->drv->exec("CREATE TABLE test(id integer primary key)"); + $this->assertException("typeViolation", "Db", "ExceptionInput"); + $this->drv->query("INSERT INTO test(id) values('ook')"); + } + + public function testPrepareAValidQuery() { + $s = $this->drv->prepare("SELECT ?, ?", "int", "int"); + $this->assertInstanceOf(Statement::class, $s); + } + + public function testPrepareAnInvalidQuery() { + $this->assertException("engineErrorGeneral", "Db"); + $s = $this->drv->prepare("This is an invalid query", "int", "int"); + } + + public function testCreateASavepoint() { + $this->assertEquals(1, $this->drv->savepointCreate()); + $this->assertEquals(2, $this->drv->savepointCreate()); + $this->assertEquals(3, $this->drv->savepointCreate()); + } + + public function testReleaseASavepoint() { + $this->assertEquals(1, $this->drv->savepointCreate()); + $this->assertEquals(true, $this->drv->savepointRelease()); + $this->assertException("savepointInvalid", "Db"); + $this->drv->savepointRelease(); + } + + public function testUndoASavepoint() { + $this->assertEquals(1, $this->drv->savepointCreate()); + $this->assertEquals(true, $this->drv->savepointUndo()); + $this->assertException("savepointInvalid", "Db"); + $this->drv->savepointUndo(); + } + + public function testManipulateSavepoints() { + $this->assertEquals(1, $this->drv->savepointCreate()); + $this->assertEquals(2, $this->drv->savepointCreate()); + $this->assertEquals(3, $this->drv->savepointCreate()); + $this->assertEquals(4, $this->drv->savepointCreate()); + $this->assertEquals(5, $this->drv->savepointCreate()); + $this->assertTrue($this->drv->savepointUndo(3)); + $this->assertFalse($this->drv->savepointRelease(4)); + $this->assertEquals(6, $this->drv->savepointCreate()); + $this->assertFalse($this->drv->savepointRelease(5)); + $this->assertTrue($this->drv->savepointRelease(6)); + $this->assertEquals(3, $this->drv->savepointCreate()); + $this->assertTrue($this->drv->savepointRelease(2)); + $this->assertException("savepointStale", "Db"); + $this->drv->savepointRelease(2); + } + + public function testManipulateSavepointsSomeMore() { + $this->assertEquals(1, $this->drv->savepointCreate()); + $this->assertEquals(2, $this->drv->savepointCreate()); + $this->assertEquals(3, $this->drv->savepointCreate()); + $this->assertEquals(4, $this->drv->savepointCreate()); + $this->assertTrue($this->drv->savepointRelease(2)); + $this->assertFalse($this->drv->savepointUndo(3)); + $this->assertException("savepointStale", "Db"); + $this->drv->savepointUndo(2); + } + + public function testBeginATransaction() { + $select = "SELECT count(*) FROM test"; + $insert = "INSERT INTO test(id) values(null)"; + $this->drv->exec("CREATE TABLE test(id integer primary key)"); + $tr = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $this->drv->query($insert); + $this->assertEquals(2, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + } + + public function testCommitATransaction() { + $select = "SELECT count(*) FROM test"; + $insert = "INSERT INTO test(id) values(null)"; + $this->drv->exec("CREATE TABLE test(id integer primary key)"); + $tr = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr->commit(); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(1, $this->ch->query($select)->fetchColumn()); + } + + public function testRollbackATransaction() { + $select = "SELECT count(*) FROM test"; + $insert = "INSERT INTO test(id) values(null)"; + $this->drv->exec("CREATE TABLE test(id integer primary key)"); + $tr = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr->rollback(); + $this->assertEquals(0, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + } + + public function testBeginChainedTransactions() { + $select = "SELECT count(*) FROM test"; + $insert = "INSERT INTO test(id) values(null)"; + $this->drv->exec("CREATE TABLE test(id integer primary key)"); + $tr1 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr2 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(2, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + } + + public function testCommitChainedTransactions() { + $select = "SELECT count(*) FROM test"; + $insert = "INSERT INTO test(id) values(null)"; + $this->drv->exec("CREATE TABLE test(id integer primary key)"); + $tr1 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr2 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(2, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr2->commit(); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr1->commit(); + $this->assertEquals(2, $this->ch->query($select)->fetchColumn()); + } + + public function testCommitChainedTransactionsOutOfOrder() { + $select = "SELECT count(*) FROM test"; + $insert = "INSERT INTO test(id) values(null)"; + $this->drv->exec("CREATE TABLE test(id integer primary key)"); + $tr1 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr2 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(2, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr1->commit(); + $this->assertEquals(2, $this->ch->query($select)->fetchColumn()); + $tr2->commit(); + } + + public function testRollbackChainedTransactions() { + $select = "SELECT count(*) FROM test"; + $insert = "INSERT INTO test(id) values(null)"; + $this->drv->exec("CREATE TABLE test(id integer primary key)"); + $tr1 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr2 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(2, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr2->rollback(); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr1->rollback(); + $this->assertEquals(0, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + } + + public function testRollbackChainedTransactionsOutOfOrder() { + $select = "SELECT count(*) FROM test"; + $insert = "INSERT INTO test(id) values(null)"; + $this->drv->exec("CREATE TABLE test(id integer primary key)"); + $tr1 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr2 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(2, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr1->rollback(); + $this->assertEquals(0, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr2->rollback(); + $this->assertEquals(0, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + } + + public function testPartiallyRollbackChainedTransactions() { + $select = "SELECT count(*) FROM test"; + $insert = "INSERT INTO test(id) values(null)"; + $this->drv->exec("CREATE TABLE test(id integer primary key)"); + $tr1 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr2 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(2, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr2->rollback(); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->ch->query($select)->fetchColumn()); + $tr1->commit(); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(1, $this->ch->query($select)->fetchColumn()); + } + + public function testFetchSchemaVersion() { + $this->assertSame(0, $this->drv->schemaVersion()); + $this->drv->exec("PRAGMA user_version=1"); + $this->assertSame(1, $this->drv->schemaVersion()); + $this->drv->exec("PRAGMA user_version=2"); + $this->assertSame(2, $this->drv->schemaVersion()); + } + + public function testLockTheDatabase() { + $this->drv->savepointCreate(true); + $this->ch->exec("PRAGMA busy_timeout = 0"); + $this->assertException(); + $this->ch->exec("CREATE TABLE test(id integer primary key)"); + } + + public function testUnlockTheDatabase() { + $this->drv->savepointCreate(true); + $this->drv->savepointRelease(); + $this->drv->savepointCreate(true); + $this->drv->savepointUndo(); + $this->assertSame(0, $this->ch->exec("CREATE TABLE test(id integer primary key)")); + } +} diff --git a/tests/cases/Db/SQLite3PDO/TestResult.php b/tests/cases/Db/SQLite3PDO/TestResult.php new file mode 100644 index 00000000..7537b874 --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/TestResult.php @@ -0,0 +1,108 @@ + */ +class TestResult extends \JKingWeb\Arsse\Test\AbstractTest { + protected $c; + + public function setUp() { + if (!PDODriver::requirementsMet()) { + $this->markTestSkipped("PDO-SQLite extension not loaded"); + } + $this->clearData(); + $c = new \PDO("sqlite::memory:", "", "", [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + $this->c = $c; + } + + public function tearDown() { + unset($this->c); + $this->clearData(); + } + + public function testConstructResult() { + $set = $this->c->query("SELECT 1"); + $this->assertInstanceOf(Result::class, new PDOResult($set)); + } + + public function testGetChangeCountAndLastInsertId() { + $this->c->query("CREATE TABLE test(col)"); + $set = $this->c->query("INSERT INTO test(col) values(1)"); + $rows = $set->rowCount(); + $id = $this->c->lastInsertID(); + $r = new PDOResult($set, [$rows,$id]); + $this->assertSame((int) $rows, $r->changes()); + $this->assertSame((int) $id, $r->lastId()); + } + + public function testIterateOverResults() { + $set = $this->c->query("SELECT 1 as col union select 2 as col union select 3 as col"); + $rows = []; + foreach (new PDOResult($set) as $index => $row) { + $rows[$index] = $row['col']; + } + $this->assertSame([0 => "1", 1 => "2", 2 => "3"], $rows); + } + + public function testIterateOverResultsTwice() { + $set = $this->c->query("SELECT 1 as col union select 2 as col union select 3 as col"); + $rows = []; + $test = new PDOResult($set); + foreach ($test as $row) { + $rows[] = $row['col']; + } + $this->assertSame(["1","2","3"], $rows); + $this->assertException("resultReused", "Db"); + foreach ($test as $row) { + $rows[] = $row['col']; + } + } + + public function testGetSingleValues() { + $set = $this->c->query("SELECT 1867 as year union select 1970 as year union select 2112 as year"); + $test = new PDOResult($set); + $this->assertEquals(1867, $test->getValue()); + $this->assertEquals(1970, $test->getValue()); + $this->assertEquals(2112, $test->getValue()); + $this->assertSame(null, $test->getValue()); + } + + public function testGetFirstValuesOnly() { + $set = $this->c->query("SELECT 1867 as year, 19 as century union select 1970 as year, 20 as century union select 2112 as year, 22 as century"); + $test = new PDOResult($set); + $this->assertEquals(1867, $test->getValue()); + $this->assertEquals(1970, $test->getValue()); + $this->assertEquals(2112, $test->getValue()); + $this->assertSame(null, $test->getValue()); + } + + public function testGetRows() { + $set = $this->c->query("SELECT '2112' as album, '2112' as track union select 'Clockwork Angels' as album, 'The Wreckers' as track"); + $rows = [ + ['album' => '2112', 'track' => '2112'], + ['album' => 'Clockwork Angels', 'track' => 'The Wreckers'], + ]; + $test = new PDOResult($set); + $this->assertEquals($rows[0], $test->getRow()); + $this->assertEquals($rows[1], $test->getRow()); + $this->assertSame(null, $test->getRow()); + } + + public function testGetAllRows() { + $set = $this->c->query("SELECT '2112' as album, '2112' as track union select 'Clockwork Angels' as album, 'The Wreckers' as track"); + $rows = [ + ['album' => '2112', 'track' => '2112'], + ['album' => 'Clockwork Angels', 'track' => 'The Wreckers'], + ]; + $test = new PDOResult($set); + $this->assertEquals($rows, $test->getAll()); + } +} diff --git a/tests/cases/Db/SQLite3PDO/TestStatement.php b/tests/cases/Db/SQLite3PDO/TestStatement.php new file mode 100644 index 00000000..8fe70861 --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/TestStatement.php @@ -0,0 +1,313 @@ + + * @covers \JKingWeb\Arsse\Db\PDOError */ +class TestStatement extends \JKingWeb\Arsse\Test\AbstractTest { + protected $c; + protected static $imp = \JKingWeb\Arsse\Db\PDOStatement::class; + + public function setUp() { + if (!PDODriver::requirementsMet()) { + $this->markTestSkipped("PDO-SQLite extension not loaded"); + } + $this->clearData(); + $c = new \PDO("sqlite::memory:", "", "", [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + $this->c = $c; + } + + public function tearDown() { + unset($this->c); + $this->clearData(); + } + + protected function checkBinding($input, array $expectations, bool $strict = false) { + $nativeStatement = $this->c->prepare("SELECT ? as value"); + $s = new self::$imp($this->c, $nativeStatement); + $types = array_unique(Statement::TYPES); + foreach ($types as $type) { + $s->retypeArray([$strict ? "strict $type" : $type]); + $val = $s->runArray([$input])->getRow()['value']; + $this->assertSame($expectations[$type], $val, "Binding from type $type failed comparison."); + $s->retype(...[$strict ? "strict $type" : $type]); + $val = $s->run(...[$input])->getRow()['value']; + $this->assertSame($expectations[$type], $val, "Binding from type $type failed comparison."); + } + } + + /** @dataProvider provideBindings */ + public function testBindATypedValue($value, $type, $exp) { + $typeStr = "'".str_replace("'", "''", $type)."'"; + $nativeStatement = $this->c->prepare( + "SELECT ( + (CASE WHEN substr($typeStr, 0, 7) <> 'strict ' then null else 1 end) is null + and ? is null + ) or ( + $exp = ? + ) as pass" + ); + $s = new self::$imp($this->c, $nativeStatement); + $s->retype(...[$type, $type]); + $act = (bool) $s->run(...[$value, $value])->getRow()['pass']; + $this->assertTrue($act); + } + + public function provideBindings() { + $dateMutable = new \DateTime("Noon Today", new \DateTimezone("America/Toronto")); + $dateImmutable = new \DateTimeImmutable("Noon Today", new \DateTimezone("America/Toronto")); + $dateUTC = new \DateTime("@".$dateMutable->getTimestamp(), new \DateTimezone("UTC")); + return [ + /* input, type, expected binding as SQL fragment */ + [null, "integer", "null"], + [null, "float", "null"], + [null, "string", "null"], + [null, "binary", "null"], + [null, "datetime", "null"], + [null, "boolean", "null"], + [null, "strict integer", "0"], + [null, "strict float", "'0'"], + [null, "strict string", "''"], + [null, "strict binary", "x''"], + [null, "strict datetime", "'1970-01-01 00:00:00'"], + [null, "strict boolean", "0"], + // true + [true, "integer", "1"], + [true, "float", "'1'"], + [true, "string", "'1'"], + [true, "binary", "x'31'"], + [true, "datetime", "null"], + [true, "boolean", "1"], + [true, "strict integer", "1"], + [true, "strict float", "'1'"], + [true, "strict string", "'1'"], + [true, "strict binary", "x'31'"], + [true, "strict datetime", "'1970-01-01 00:00:00'"], + [true, "strict boolean", "1"], + // false + [false, "integer", "0"], + [false, "float", "'0'"], + [false, "string", "''"], + [false, "binary", "x''"], + [false, "datetime", "null"], + [false, "boolean", "0"], + [false, "strict integer", "0"], + [false, "strict float", "'0'"], + [false, "strict string", "''"], + [false, "strict binary", "x''"], + [false, "strict datetime", "'1970-01-01 00:00:00'"], + [false, "strict boolean", "0"], + // integer + [2112, "integer", "2112"], + [2112, "float", "'2112'"], + [2112, "string", "'2112'"], + [2112, "binary", "x'32313132'"], + [2112, "datetime", "'1970-01-01 00:35:12'"], + [2112, "boolean", "1"], + [2112, "strict integer", "2112"], + [2112, "strict float", "'2112'"], + [2112, "strict string", "'2112'"], + [2112, "strict binary", "x'32313132'"], + [2112, "strict datetime", "'1970-01-01 00:35:12'"], + [2112, "strict boolean", "1"], + // integer zero + [0, "integer", "0"], + [0, "float", "'0'"], + [0, "string", "'0'"], + [0, "binary", "x'30'"], + [0, "datetime", "'1970-01-01 00:00:00'"], + [0, "boolean", "0"], + [0, "strict integer", "0"], + [0, "strict float", "'0'"], + [0, "strict string", "'0'"], + [0, "strict binary", "x'30'"], + [0, "strict datetime", "'1970-01-01 00:00:00'"], + [0, "strict boolean", "0"], + // float + [2112.5, "integer", "2112"], + [2112.5, "float", "'2112.5'"], + [2112.5, "string", "'2112.5'"], + [2112.5, "binary", "x'323131322e35'"], + [2112.5, "datetime", "'1970-01-01 00:35:12'"], + [2112.5, "boolean", "1"], + [2112.5, "strict integer", "2112"], + [2112.5, "strict float", "'2112.5'"], + [2112.5, "strict string", "'2112.5'"], + [2112.5, "strict binary", "x'323131322e35'"], + [2112.5, "strict datetime", "'1970-01-01 00:35:12'"], + [2112.5, "strict boolean", "1"], + // float zero + [0.0, "integer", "0"], + [0.0, "float", "'0'"], + [0.0, "string", "'0'"], + [0.0, "binary", "x'30'"], + [0.0, "datetime", "'1970-01-01 00:00:00'"], + [0.0, "boolean", "0"], + [0.0, "strict integer", "0"], + [0.0, "strict float", "'0'"], + [0.0, "strict string", "'0'"], + [0.0, "strict binary", "x'30'"], + [0.0, "strict datetime", "'1970-01-01 00:00:00'"], + [0.0, "strict boolean", "0"], + // ASCII string + ["Random string", "integer", "0"], + ["Random string", "float", "'0'"], + ["Random string", "string", "'Random string'"], + ["Random string", "binary", "x'52616e646f6d20737472696e67'"], + ["Random string", "datetime", "null"], + ["Random string", "boolean", "1"], + ["Random string", "strict integer", "0"], + ["Random string", "strict float", "'0'"], + ["Random string", "strict string", "'Random string'"], + ["Random string", "strict binary", "x'52616e646f6d20737472696e67'"], + ["Random string", "strict datetime", "'1970-01-01 00:00:00'"], + ["Random string", "strict boolean", "1"], + // UTF-8 string + ["é", "integer", "0"], + ["é", "float", "'0'"], + ["é", "string", "char(233)"], + ["é", "binary", "x'c3a9'"], + ["é", "datetime", "null"], + ["é", "boolean", "1"], + ["é", "strict integer", "0"], + ["é", "strict float", "'0'"], + ["é", "strict string", "char(233)"], + ["é", "strict binary", "x'c3a9'"], + ["é", "strict datetime", "'1970-01-01 00:00:00'"], + ["é", "strict boolean", "1"], + // binary string + [chr(233).chr(233), "integer", "0"], + [chr(233).chr(233), "float", "'0'"], + [chr(233).chr(233), "string", "'".chr(233).chr(233)."'"], + [chr(233).chr(233), "binary", "x'e9e9'"], + [chr(233).chr(233), "datetime", "null"], + [chr(233).chr(233), "boolean", "1"], + [chr(233).chr(233), "strict integer", "0"], + [chr(233).chr(233), "strict float", "'0'"], + [chr(233).chr(233), "strict string", "'".chr(233).chr(233)."'"], + [chr(233).chr(233), "strict binary", "x'e9e9'"], + [chr(233).chr(233), "strict datetime", "'1970-01-01 00:00:00'"], + [chr(233).chr(233), "strict boolean", "1"], + // ISO 8601 date string + ["2017-01-09T13:11:17", "integer", "0"], + ["2017-01-09T13:11:17", "float", "'0'"], + ["2017-01-09T13:11:17", "string", "'2017-01-09T13:11:17'"], + ["2017-01-09T13:11:17", "binary", "x'323031372d30312d30395431333a31313a3137'"], + ["2017-01-09T13:11:17", "datetime", "'2017-01-09 13:11:17'"], + ["2017-01-09T13:11:17", "boolean", "1"], + ["2017-01-09T13:11:17", "strict integer", "0"], + ["2017-01-09T13:11:17", "strict float", "'0'"], + ["2017-01-09T13:11:17", "strict string", "'2017-01-09T13:11:17'"], + ["2017-01-09T13:11:17", "strict binary", "x'323031372d30312d30395431333a31313a3137'"], + ["2017-01-09T13:11:17", "strict datetime", "'2017-01-09 13:11:17'"], + ["2017-01-09T13:11:17", "strict boolean", "1"], + // arbitrary date string + ["Today", "integer", "0"], + ["Today", "float", "'0'"], + ["Today", "string", "'Today'"], + ["Today", "binary", "x'546f646179'"], + ["Today", "datetime", "'".date_create("Today", new \DateTimezone("UTC"))->format("Y-m-d H:i:s")."'"], + ["Today", "boolean", "1"], + ["Today", "strict integer", "0"], + ["Today", "strict float", "'0'"], + ["Today", "strict string", "'Today'"], + ["Today", "strict binary", "x'546f646179'"], + ["Today", "strict datetime", "'".date_create("Today", new \DateTimezone("UTC"))->format("Y-m-d H:i:s")."'"], + ["Today", "strict boolean", "1"], + // mutable date object + [$dateMutable, "integer", $dateUTC->getTimestamp()], + [$dateMutable, "float", "'".$dateUTC->getTimestamp()."'"], + [$dateMutable, "string", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + [$dateMutable, "binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"], + [$dateMutable, "datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + [$dateMutable, "boolean", "1"], + [$dateMutable, "strict integer", $dateUTC->getTimestamp()], + [$dateMutable, "strict float", "'".$dateUTC->getTimestamp()."'"], + [$dateMutable, "strict string", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + [$dateMutable, "strict binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"], + [$dateMutable, "strict datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + [$dateMutable, "strict boolean", "1"], + // immutable date object + [$dateImmutable, "integer", $dateUTC->getTimestamp()], + [$dateImmutable, "float", "'".$dateUTC->getTimestamp()."'"], + [$dateImmutable, "string", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + [$dateImmutable, "binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"], + [$dateImmutable, "datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + [$dateImmutable, "boolean", "1"], + [$dateImmutable, "strict integer", $dateUTC->getTimestamp()], + [$dateImmutable, "strict float", "'".$dateUTC->getTimestamp()."'"], + [$dateImmutable, "strict string", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + [$dateImmutable, "strict binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"], + [$dateImmutable, "strict datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + [$dateImmutable, "strict boolean", "1"], + ]; + } + + public function testConstructStatement() { + $nativeStatement = $this->c->prepare("SELECT ? as value"); + $this->assertInstanceOf(Statement::class, new PDOStatement($this->c, $nativeStatement)); + } + + public function testBindMissingValue() { + $nativeStatement = $this->c->prepare("SELECT ? as value"); + $s = new self::$imp($this->c, $nativeStatement); + $val = $s->runArray()->getRow()['value']; + $this->assertSame(null, $val); + } + + public function testBindMultipleValues() { + $exp = [ + 'one' => "1", + 'two' => "2", + ]; + $nativeStatement = $this->c->prepare("SELECT ? as one, ? as two"); + $s = new self::$imp($this->c, $nativeStatement, ["int", "int"]); + $val = $s->runArray([1,2])->getRow(); + $this->assertSame($exp, $val); + } + + public function testBindRecursively() { + $exp = [ + 'one' => "1", + 'two' => "2", + 'three' => "3", + 'four' => "4", + ]; + $nativeStatement = $this->c->prepare("SELECT ? as one, ? as two, ? as three, ? as four"); + $s = new self::$imp($this->c, $nativeStatement, ["int", ["int", "int"], "int"]); + $val = $s->runArray([1, [2, 3], 4])->getRow(); + $this->assertSame($exp, $val); + } + + public function testBindWithoutType() { + $nativeStatement = $this->c->prepare("SELECT ? as value"); + $this->assertException("paramTypeMissing", "Db"); + $s = new self::$imp($this->c, $nativeStatement, []); + $s->runArray([1]); + } + + public function testViolateConstraint() { + $this->c->exec("CREATE TABLE test(id integer not null)"); + $nativeStatement = $this->c->prepare("INSERT INTO test(id) values(?)"); + $s = new self::$imp($this->c, $nativeStatement, ["int"]); + $this->assertException("constraintViolation", "Db", "ExceptionInput"); + $s->runArray([null]); + } + + public function testMismatchTypes() { + $this->c->exec("CREATE TABLE test(id integer primary key)"); + $nativeStatement = $this->c->prepare("INSERT INTO test(id) values(?)"); + $s = new self::$imp($this->c, $nativeStatement, ["str"]); + $this->assertException("typeViolation", "Db", "ExceptionInput"); + $s->runArray(['ook']); + } +} diff --git a/tests/cases/Db/SQLite3PDO/TestUpdate.php b/tests/cases/Db/SQLite3PDO/TestUpdate.php new file mode 100644 index 00000000..9c8df845 --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/TestUpdate.php @@ -0,0 +1,125 @@ + + * @covers \JKingWeb\Arsse\Db\PDOError */ +class TestUpdate extends \JKingWeb\Arsse\Test\AbstractTest { + protected $data; + protected $drv; + protected $vfs; + protected $base; + + const MINIMAL1 = "create table arsse_meta(key text primary key not null, value text); pragma user_version=1"; + const MINIMAL2 = "pragma user_version=2"; + + public function setUp(Conf $conf = null) { + if (!PDODriver::requirementsMet()) { + $this->markTestSkipped("PDO-SQLite extension not loaded"); + } + $this->clearData(); + $this->vfs = vfsStream::setup("schemata", null, ['SQLite3' => []]); + if (!$conf) { + $conf = new Conf(); + } + $conf->dbDriver = PDODriver::class; + $conf->dbSQLite3File = ":memory:"; + Arsse::$conf = $conf; + $this->base = $this->vfs->url(); + $this->path = $this->base."/SQLite3/"; + $this->drv = new PDODriver(); + } + + public function tearDown() { + unset($this->drv); + unset($this->data); + unset($this->vfs); + $this->clearData(); + } + + public function testLoadMissingFile() { + $this->assertException("updateFileMissing", "Db"); + $this->drv->schemaUpdate(1, $this->base); + } + + public function testLoadUnreadableFile() { + touch($this->path."0.sql"); + chmod($this->path."0.sql", 0000); + $this->assertException("updateFileUnreadable", "Db"); + $this->drv->schemaUpdate(1, $this->base); + } + + public function testLoadCorruptFile() { + file_put_contents($this->path."0.sql", "This is a corrupt file"); + $this->assertException("updateFileError", "Db"); + $this->drv->schemaUpdate(1, $this->base); + } + + public function testLoadIncompleteFile() { + file_put_contents($this->path."0.sql", "create table arsse_meta(key text primary key not null, value text);"); + $this->assertException("updateFileIncomplete", "Db"); + $this->drv->schemaUpdate(1, $this->base); + } + + public function testLoadEmptyFile() { + file_put_contents($this->path."0.sql", ""); + $this->assertException("updateFileIncomplete", "Db"); + $this->drv->schemaUpdate(1, $this->base); + } + + public function testLoadCorrectFile() { + file_put_contents($this->path."0.sql", self::MINIMAL1); + $this->drv->schemaUpdate(1, $this->base); + $this->assertEquals(1, $this->drv->schemaVersion()); + } + + public function testPerformPartialUpdate() { + file_put_contents($this->path."0.sql", self::MINIMAL1); + file_put_contents($this->path."1.sql", " "); + $this->assertException("updateFileIncomplete", "Db"); + try { + $this->drv->schemaUpdate(2, $this->base); + } catch (Exception $e) { + $this->assertEquals(1, $this->drv->schemaVersion()); + throw $e; + } + } + + public function testPerformSequentialUpdate() { + file_put_contents($this->path."0.sql", self::MINIMAL1); + file_put_contents($this->path."1.sql", self::MINIMAL2); + $this->drv->schemaUpdate(2, $this->base); + $this->assertEquals(2, $this->drv->schemaVersion()); + } + + public function testPerformActualUpdate() { + $this->drv->schemaUpdate(Database::SCHEMA_VERSION); + $this->assertEquals(Database::SCHEMA_VERSION, $this->drv->schemaVersion()); + } + + public function testDeclineManualUpdate() { + // turn auto-updating off + $conf = new Conf(); + $conf->dbAutoUpdate = false; + $this->setUp($conf); + $this->assertException("updateManual", "Db"); + $this->drv->schemaUpdate(Database::SCHEMA_VERSION); + } + + public function testDeclineDowngrade() { + $this->assertException("updateTooNew", "Db"); + $this->drv->schemaUpdate(-1, $this->base); + } +} diff --git a/tests/cases/REST/NextCloudNews/PDO/TestV1_2.php b/tests/cases/REST/NextCloudNews/PDO/TestV1_2.php new file mode 100644 index 00000000..c6a3ef77 --- /dev/null +++ b/tests/cases/REST/NextCloudNews/PDO/TestV1_2.php @@ -0,0 +1,12 @@ + */ +class TestV1_2 extends \JKingWeb\Arsse\TestCase\REST\NextCloudNews\TestV1_2 { + use \JKingWeb\Arsse\Test\PDOTest; +} diff --git a/tests/cases/REST/NextCloudNews/TestV1_2.php b/tests/cases/REST/NextCloudNews/TestV1_2.php index 17c4679d..f73e7852 100644 --- a/tests/cases/REST/NextCloudNews/TestV1_2.php +++ b/tests/cases/REST/NextCloudNews/TestV1_2.php @@ -317,6 +317,10 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $this->clearData(); } + protected function v($value) { + return $value; + } + protected function assertResponse(Response $exp, Response $act, string $text = null) { $this->assertEquals($exp, $act, $text); $this->assertSame($exp->payload, $act->payload, $text); @@ -404,13 +408,13 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { public function testListFolders() { $list = [ ['id' => 1, 'name' => "Software", 'parent' => null], - ['id' => "12", 'name' => "Hardware", 'parent' => null], + ['id' => 12, 'name' => "Hardware", 'parent' => null], ]; $out = [ ['id' => 1, 'name' => "Software"], ['id' => 12, 'name' => "Hardware"], ]; - Phake::when(Arsse::$db)->folderList(Arsse::$user->id, null, false)->thenReturn(new Result([]))->thenReturn(new Result($list)); + Phake::when(Arsse::$db)->folderList(Arsse::$user->id, null, false)->thenReturn(new Result([]))->thenReturn(new Result($this->v($list))); $exp = new Response(200, ['folders' => []]); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/folders"))); $exp = new Response(200, ['folders' => $out]); @@ -434,8 +438,8 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->folderAdd($this->anything(), $this->anything())->thenThrow(new \Exception); Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, $in[0])->thenReturn(1)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, $in[1])->thenReturn(2)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call - Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 1)->thenReturn($db[0]); - Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 2)->thenReturn($db[1]); + Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 1)->thenReturn($this->v($db[0])); + Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 2)->thenReturn($this->v($db[1])); // set up mocks that produce errors Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, [])->thenThrow(new ExceptionInput("missing")); Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => ""])->thenThrow(new ExceptionInput("missing")); @@ -518,8 +522,8 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { 'starredCount' => 5, 'newestItemId' => 4758915, ]; - Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([]))->thenReturn(new Result($this->feeds['db'])); - Phake::when(Arsse::$db)->articleStarred(Arsse::$user->id)->thenReturn(['total' => 0])->thenReturn(['total' => 5]); + Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([]))->thenReturn(new Result($this->v($this->feeds['db']))); + Phake::when(Arsse::$db)->articleStarred(Arsse::$user->id)->thenReturn($this->v(['total' => 0]))->thenReturn($this->v(['total' => 5])); Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id)->thenReturn(0)->thenReturn(4758915); $exp = new Response(200, $exp1); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds"))); @@ -544,9 +548,9 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.com/news.atom")->thenReturn(2112)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.org/news.atom")->thenReturn(42)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "")->thenThrow(new \JKingWeb\Arsse\Feed\Exception("", new \PicoFeed\Reader\SubscriptionNotFoundException)); - Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 2112)->thenReturn($this->feeds['db'][0]); - Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 42)->thenReturn($this->feeds['db'][1]); - Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 47)->thenReturn($this->feeds['db'][2]); + Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 2112)->thenReturn($this->v($this->feeds['db'][0])); + Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 42)->thenReturn($this->v($this->feeds['db'][1])); + Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 47)->thenReturn($this->v($this->feeds['db'][2])); Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id, (new Context)->subscription(2112))->thenReturn(0); Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id, (new Context)->subscription(42))->thenReturn(4758915); Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id, (new Context)->subscription(47))->thenReturn(2112); @@ -654,7 +658,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { 'userId' => "", ], ]; - Phake::when(Arsse::$db)->feedListStale->thenReturn(array_column($out, "id")); + Phake::when(Arsse::$db)->feedListStale->thenReturn($this->v(array_column($out, "id"))); $exp = new Response(200, ['feeds' => $out]); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/all"))); // retrieving the list when not an admin fails @@ -689,7 +693,6 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { } public function testListArticles() { - $res = new Result($this->articles['db']); $t = new \DateTime; $in = [ ['type' => 0, 'id' => 42], // type=0 => subscription/feed @@ -705,7 +708,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ['lastModified' => $t->getTimestamp()], ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], // offset=0 should not set the latestEdition context ]; - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, $this->anything(), Database::LIST_TYPICAL)->thenReturn($res); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, $this->anything(), Database::LIST_TYPICAL)->thenReturn(new Result($this->v($this->articles['db']))); Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("idMissing")); Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("idMissing")); Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("typeViolation")); diff --git a/tests/cases/REST/TinyTinyRSS/PDO/TestAPI.php b/tests/cases/REST/TinyTinyRSS/PDO/TestAPI.php new file mode 100644 index 00000000..9c9e36f1 --- /dev/null +++ b/tests/cases/REST/TinyTinyRSS/PDO/TestAPI.php @@ -0,0 +1,13 @@ + + * @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Exception */ +class TestAPI extends \JKingWeb\Arsse\TestCase\REST\TinyTinyRSS\TestAPI { + use \JKingWeb\Arsse\Test\PDOTest; +} \ No newline at end of file diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index 6844e9a7..c4be5139 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -122,6 +122,10 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { LONG_STRING; + protected function v($value) { + return $value; + } + protected function req($data) : Response { return $this->h->dispatch(new Request("POST", "", json_encode($data))); } @@ -320,8 +324,8 @@ LONG_STRING; // set of various mocks for testing Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, $db[0])->thenReturn(2)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, $db[1])->thenReturn(3)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call - Phake::when(Arsse::$db)->folderList(Arsse::$user->id, null, false)->thenReturn(new Result([$out[0], $out[2]])); - Phake::when(Arsse::$db)->folderList(Arsse::$user->id, 1, false)->thenReturn(new Result([$out[1]])); + Phake::when(Arsse::$db)->folderList(Arsse::$user->id, null, false)->thenReturn(new Result($this->v([$out[0], $out[2]]))); + Phake::when(Arsse::$db)->folderList(Arsse::$user->id, 1, false)->thenReturn(new Result($this->v([$out[1]]))); // set up mocks that produce errors Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, $db[2])->thenThrow(new ExceptionInput("idMissing")); // parent folder does not exist Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, [])->thenThrow(new ExceptionInput("missing")); @@ -522,12 +526,12 @@ LONG_STRING; Phake::when(Arsse::$db)->subscriptionAdd(...$db[7])->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/7", new \PicoFeed\Parser\MalformedXmlException())); Phake::when(Arsse::$db)->subscriptionAdd(...$db[8])->thenReturn(4); Phake::when(Arsse::$db)->subscriptionAdd(...$db[9])->thenThrow(new ExceptionInput("constraintViolation")); - Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 42)->thenReturn(['id' => 42]); - Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 47)->thenReturn(['id' => 47]); + Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 42)->thenReturn($this->v(['id' => 42])); + Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 47)->thenReturn($this->v(['id' => 47])); Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 2112)->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything())->thenReturn(true); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 4, $this->anything())->thenThrow(new ExceptionInput("idMissing")); - Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result($list)); + Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result($this->v($list))); for ($a = 0; $a < (sizeof($in) - 4); $a++) { $exp = $this->respGood($out[$a]); $this->assertResponse($exp, $this->req($in[$a]), "Failed test $a"); @@ -647,11 +651,11 @@ LONG_STRING; public function testRetrieveTheGlobalUnreadCount() { $in = ['op' => "getUnread", 'sid' => "PriestsOfSyrinx"]; - Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([ + Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result($this->v([ ['id' => 1, 'unread' => 2112], ['id' => 2, 'unread' => 42], ['id' => 3, 'unread' => 47], - ])); + ]))); $exp = $this->respGood(['unread' => (string) (2112 + 42 + 47)]); $this->assertResponse($exp, $this->req($in)); } @@ -679,7 +683,7 @@ LONG_STRING; ['op' => "updateFeed", 'sid' => "PriestsOfSyrinx"], ]; Phake::when(Arsse::$db)->feedUpdate(11)->thenReturn(true); - Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 1)->thenReturn(['id' => 1, 'feed' => 11]); + Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 1)->thenReturn($this->v(['id' => 1, 'feed' => 11])); Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 2)->thenThrow(new ExceptionInput("subjectMissing")); $exp = $this->respGood(['status' => "OK"]); $this->assertResponse($exp, $this->req($in[0])); @@ -711,8 +715,8 @@ LONG_STRING; // set of various mocks for testing Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, $db[0])->thenReturn(2)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, $db[1])->thenReturn(3)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call - Phake::when(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Software", true)->thenReturn($out[0]); - Phake::when(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Hardware", true)->thenReturn($out[1]); + Phake::when(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Software", true)->thenReturn($this->v($out[0])); + Phake::when(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Hardware", true)->thenReturn($this->v($out[1])); // set up mocks that produce errors Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, [])->thenThrow(new ExceptionInput("missing")); Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, ['name' => ""])->thenThrow(new ExceptionInput("missing")); @@ -819,12 +823,12 @@ LONG_STRING; ['op' => "getCategories", 'sid' => "PriestsOfSyrinx", 'enable_nested' => true], ['op' => "getCategories", 'sid' => "PriestsOfSyrinx", 'enable_nested' => true, 'unread_only' => true], ]; - Phake::when(Arsse::$db)->folderList($this->anything(), null, true)->thenReturn(new Result($this->folders)); - Phake::when(Arsse::$db)->folderList($this->anything(), null, false)->thenReturn(new Result($this->topFolders)); - Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->subscriptions)); - Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->labels)); + Phake::when(Arsse::$db)->folderList($this->anything(), null, true)->thenReturn(new Result($this->v($this->folders))); + Phake::when(Arsse::$db)->folderList($this->anything(), null, false)->thenReturn(new Result($this->v($this->topFolders))); + Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->v($this->subscriptions))); + Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->v($this->labels))); Phake::when(Arsse::$db)->articleCount($this->anything(), $this->anything())->thenReturn(7); // FIXME: this should check an unread+modifiedSince context - Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->starred); + Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($this->starred)); $exp = [ [ ['id' => "5", 'title' => "Local", 'unread' => 10, 'order_id' => 1], @@ -884,11 +888,11 @@ LONG_STRING; public function testRetrieveCounterList() { $in = ['op' => "getCounters", 'sid' => "PriestsOfSyrinx"]; - Phake::when(Arsse::$db)->folderList($this->anything())->thenReturn(new Result($this->folders)); - Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->subscriptions)); - Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->usedLabels)); + Phake::when(Arsse::$db)->folderList($this->anything())->thenReturn(new Result($this->v($this->folders))); + Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->v($this->subscriptions))); + Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->v($this->usedLabels))); Phake::when(Arsse::$db)->articleCount($this->anything(), $this->anything())->thenReturn(7); // FIXME: this should check an unread+modifiedSince context - Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->starred); + Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($this->starred)); $exp = [ ['id' => "global-unread", 'counter' => 35], ['id' => "subscribed-feeds", 'counter' => 6], @@ -925,9 +929,9 @@ LONG_STRING; ['op' => "getLabels", 'sid' => "PriestsOfSyrinx", 'article_id' => 3], ['op' => "getLabels", 'sid' => "PriestsOfSyrinx", 'article_id' => 4], ]; - Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->labels)); - Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 1)->thenReturn([1,3]); - Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 2)->thenReturn([3]); + Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->v($this->labels))); + Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 1)->thenReturn($this->v([1,3])); + Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 2)->thenReturn($this->v([3])); Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 3)->thenReturn([]); Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 4)->thenThrow(new ExceptionInput("idMissing")); $exp = [ @@ -1005,11 +1009,11 @@ LONG_STRING; ['op' => "getFeedTree", 'sid' => "PriestsOfSyrinx", 'include_empty' => true], ['op' => "getFeedTree", 'sid' => "PriestsOfSyrinx"], ]; - Phake::when(Arsse::$db)->folderList($this->anything(), null, true)->thenReturn(new Result($this->folders)); - Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->subscriptions)); - Phake::when(Arsse::$db)->labelList($this->anything(), true)->thenReturn(new Result($this->labels)); + Phake::when(Arsse::$db)->folderList($this->anything(), null, true)->thenReturn(new Result($this->v($this->folders))); + Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->v($this->subscriptions))); + Phake::when(Arsse::$db)->labelList($this->anything(), true)->thenReturn(new Result($this->v($this->labels))); Phake::when(Arsse::$db)->articleCount($this->anything(), $this->anything())->thenReturn(7); // FIXME: this should check an unread+modifiedSince context - Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->starred); + Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($this->starred)); // the expectations are packed tightly since they're very verbose; one can use var_export() (or convert to JSON) to pretty-print them $exp = ['categories'=>['identifier'=>'id','label'=>'name','items'=>[['name'=>'Special','id'=>'CAT:-1','bare_id'=>-1,'type'=>'category','unread'=>0,'items'=>[['name'=>'All articles','id'=>'FEED:-4','bare_id'=>-4,'icon'=>'images/folder.png','unread'=>35,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Fresh articles','id'=>'FEED:-3','bare_id'=>-3,'icon'=>'images/fresh.png','unread'=>7,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Starred articles','id'=>'FEED:-1','bare_id'=>-1,'icon'=>'images/star.png','unread'=>4,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Published articles','id'=>'FEED:-2','bare_id'=>-2,'icon'=>'images/feed.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Archived articles','id'=>'FEED:0','bare_id'=>0,'icon'=>'images/archive.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Recently read','id'=>'FEED:-6','bare_id'=>-6,'icon'=>'images/time.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],],],['name'=>'Labels','id'=>'CAT:-2','bare_id'=>-2,'type'=>'category','unread'=>6,'items'=>[['name'=>'Fascinating','id'=>'FEED:-1027','bare_id'=>-1027,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Interesting','id'=>'FEED:-1029','bare_id'=>-1029,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Logical','id'=>'FEED:-1025','bare_id'=>-1025,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],],],['name'=>'Photography','id'=>'CAT:4','bare_id'=>4,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(0 feeds)','items'=>[],],['name'=>'Politics','id'=>'CAT:3','bare_id'=>3,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(3 feeds)','items'=>[['name'=>'Local','id'=>'CAT:5','bare_id'=>5,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'Toronto Star','id'=>'FEED:2','bare_id'=>2,'icon'=>'feed-icons/2.ico','error'=>'oops','param'=>'2011-11-11T11:11:11Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'National','id'=>'CAT:6','bare_id'=>6,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'CBC News','id'=>'FEED:4','bare_id'=>4,'icon'=>'feed-icons/4.ico','error'=>'','param'=>'2017-10-09T15:58:34Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],['name'=>'Ottawa Citizen','id'=>'FEED:5','bare_id'=>5,'icon'=>false,'error'=>'','param'=>'2017-07-07T17:07:17Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],['name'=>'Science','id'=>'CAT:1','bare_id'=>1,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'Rocketry','id'=>'CAT:2','bare_id'=>2,'parent_id'=>1,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'NASA JPL','id'=>'FEED:1','bare_id'=>1,'icon'=>false,'error'=>'','param'=>'2017-09-15T22:54:16Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Ars Technica','id'=>'FEED:3','bare_id'=>3,'icon'=>'feed-icons/3.ico','error'=>'argh','param'=>'2016-05-23T06:40:02Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Uncategorized','id'=>'CAT:0','bare_id'=>0,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'parent_id'=>null,'param'=>'(1 feed)','items'=>[['name'=>'Eurogamer','id'=>'FEED:6','bare_id'=>6,'icon'=>'feed-icons/6.ico','error'=>'','param'=>'2010-02-12T20:08:47Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],]; $this->assertResponse($this->respGood($exp), $this->req($in[0])); @@ -1094,24 +1098,24 @@ LONG_STRING; ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 6, 'offset' => 2], ]; // statistical mocks - Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->starred); + Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($this->starred)); Phake::when(Arsse::$db)->articleCount->thenReturn(7); // FIXME: this should check an unread+modifiedSince context Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(35); // label mocks - Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->labels)); - Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->usedLabels)); + Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->v($this->labels))); + Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->v($this->usedLabels))); // subscription and folder list and unread count mocks Phake::when(Arsse::$db)->folderList->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->subscriptionList->thenThrow(new ExceptionInput("subjectMissing")); - Phake::when(Arsse::$db)->folderList($this->anything())->thenReturn(new Result($this->folders)); - Phake::when(Arsse::$db)->subscriptionList($this->anything(), null, true)->thenReturn(new Result($this->subscriptions)); - Phake::when(Arsse::$db)->subscriptionList($this->anything(), null, false)->thenReturn(new Result($this->filterSubs(null))); - Phake::when(Arsse::$db)->folderList($this->anything(), null)->thenReturn(new Result($this->folders)); - Phake::when(Arsse::$db)->folderList($this->anything(), null, false)->thenReturn(new Result($this->filterFolders(null))); + Phake::when(Arsse::$db)->folderList($this->anything())->thenReturn(new Result($this->v($this->folders))); + Phake::when(Arsse::$db)->subscriptionList($this->anything(), null, true)->thenReturn(new Result($this->v($this->subscriptions))); + Phake::when(Arsse::$db)->subscriptionList($this->anything(), null, false)->thenReturn(new Result($this->v($this->filterSubs(null)))); + Phake::when(Arsse::$db)->folderList($this->anything(), null)->thenReturn(new Result($this->v($this->folders))); + Phake::when(Arsse::$db)->folderList($this->anything(), null, false)->thenReturn(new Result($this->v($this->filterFolders(null)))); foreach ($this->folders as $f) { - Phake::when(Arsse::$db)->folderList($this->anything(), $f['id'], false)->thenReturn(new Result($this->filterFolders($f['id']))); + Phake::when(Arsse::$db)->folderList($this->anything(), $f['id'], false)->thenReturn(new Result($this->v($this->filterFolders($f['id'])))); Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true)->folder($f['id']))->thenReturn($this->reduceFolders($f['id'])); - Phake::when(Arsse::$db)->subscriptionList($this->anything(), $f['id'], false)->thenReturn(new Result($this->filterSubs($f['id']))); + Phake::when(Arsse::$db)->subscriptionList($this->anything(), $f['id'], false)->thenReturn(new Result($this->v($this->filterSubs($f['id'])))); } $exp = [ [ @@ -1263,10 +1267,10 @@ LONG_STRING; ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 4], // invalid field ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "0, -1", 'field' => 3], // no valid IDs ]; - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([42, 2112])->starred(true), $this->anything())->thenReturn(new Result([['id' => 42]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([42, 2112])->starred(false), $this->anything())->thenReturn(new Result([['id' => 2112]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([42, 2112])->unread(true), $this->anything())->thenReturn(new Result([['id' => 42]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([42, 2112])->unread(false), $this->anything())->thenReturn(new Result([['id' => 2112]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([42, 2112])->starred(true), $this->anything())->thenReturn(new Result($this->v([['id' => 42]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([42, 2112])->starred(false), $this->anything())->thenReturn(new Result($this->v([['id' => 2112]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([42, 2112])->unread(true), $this->anything())->thenReturn(new Result($this->v([['id' => 42]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([42, 2112])->unread(false), $this->anything())->thenReturn(new Result($this->v([['id' => 2112]]))); Phake::when(Arsse::$db)->articleMark->thenReturn(1); Phake::when(Arsse::$db)->articleMark($this->anything(), ['starred' => false], (new Context)->articles([42, 2112]))->thenReturn(2); Phake::when(Arsse::$db)->articleMark($this->anything(), ['starred' => true], (new Context)->articles([42, 2112]))->thenReturn(4); @@ -1327,13 +1331,13 @@ LONG_STRING; ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => "101"], ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => "102"], ]; - Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->labels)); - Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->usedLabels)); + Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->v($this->labels))); + Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->v($this->usedLabels))); Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 101)->thenReturn([]); - Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 102)->thenReturn([1,3]); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([101, 102]))->thenReturn(new Result($this->articles)); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([101]))->thenReturn(new Result([$this->articles[0]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([102]))->thenReturn(new Result([$this->articles[1]])); + Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 102)->thenReturn($this->v([1,3])); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([101, 102]))->thenReturn(new Result($this->v($this->articles))); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([101]))->thenReturn(new Result($this->v([$this->articles[0]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([102]))->thenReturn(new Result($this->v([$this->articles[1]]))); $exp = $this->respErr("INCORRECT_USAGE"); $this->assertResponse($exp, $this->req($in[0])); $this->assertResponse($exp, $this->req($in[1])); @@ -1436,22 +1440,22 @@ LONG_STRING; ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3], ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3, 'view_mode' => "marked"], ]; - Phake::when(Arsse::$db)->articleList->thenReturn(new Result([['id' => 0]])); + Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->v([['id' => 0]]))); Phake::when(Arsse::$db)->articleCount->thenReturn(0); Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1); $c = (new Context)->reverse(true); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), Database::LIST_MINIMAL)->thenThrow(new ExceptionInput("subjectMissing")); - Phake::when(Arsse::$db)->articleList($this->anything(), $c, Database::LIST_MINIMAL)->thenReturn(new Result($this->articles)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 2]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 3]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 4]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 5]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 6]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 7]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 8]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 9]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 10]])); + Phake::when(Arsse::$db)->articleList($this->anything(), $c, Database::LIST_MINIMAL)->thenReturn(new Result($this->v($this->articles))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 1]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 2]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 3]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 4]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 5]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 6]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 7]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 8]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 9]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 10]]))); $out1 = [ $this->respErr("INCORRECT_USAGE"), $this->respGood([]), @@ -1483,9 +1487,9 @@ LONG_STRING; $this->assertResponse($out1[$a], $this->req($in1[$a]), "Test $a failed"); } for ($a = 0; $a < sizeof($in2); $a++) { - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(false)->markedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1001]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1002]])); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1003]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(false)->markedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 1001]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 1002]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['id' => 1003]]))); $this->assertResponse($out2[$a], $this->req($in2[$a]), "Test $a failed"); } } @@ -1531,10 +1535,10 @@ LONG_STRING; ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3], ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3, 'view_mode' => "marked"], ]; - Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->labels)); - Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->usedLabels)); + Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->v($this->labels))); + Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->v($this->usedLabels))); Phake::when(Arsse::$db)->articleLabelsGet->thenReturn([]); - Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 2112)->thenReturn([1,3]); + Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 2112)->thenReturn($this->v([1,3])); Phake::when(Arsse::$db)->articleCategoriesGet->thenReturn([]); Phake::when(Arsse::$db)->articleCategoriesGet($this->anything(), 2112)->thenReturn(["Boring","Illogical"]); Phake::when(Arsse::$db)->articleList->thenReturn($this->generateHeadlines(0)); @@ -1616,10 +1620,10 @@ LONG_STRING; ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'skip' => 47, 'include_header' => true, 'order_by' => "date_reverse"], ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'show_excerpt' => true], ]; - Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->labels)); - Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->usedLabels)); + Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->v($this->labels))); + Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->v($this->usedLabels))); Phake::when(Arsse::$db)->articleLabelsGet->thenReturn([]); - Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 2112)->thenReturn([1,3]); + Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 2112)->thenReturn($this->v([1,3])); Phake::when(Arsse::$db)->articleCategoriesGet->thenReturn([]); Phake::when(Arsse::$db)->articleCategoriesGet($this->anything(), 2112)->thenReturn(["Boring","Illogical"]); Phake::when(Arsse::$db)->articleList->thenReturn($this->generateHeadlines(1)); @@ -1720,7 +1724,7 @@ LONG_STRING; } protected function generateHeadlines(int $id): Result { - return new Result([ + return new Result($this->v([ [ 'id' => $id, 'url' => 'http://example.com/1', @@ -1761,7 +1765,7 @@ LONG_STRING; 'media_type' => "text/plain", 'note' => "Note 2", ], - ]); + ])); } protected function outputHeadlines(int $id): Response { diff --git a/tests/cases/User/TestAuthorization.php b/tests/cases/User/TestAuthorization.php index 5692f019..088f6748 100644 --- a/tests/cases/User/TestAuthorization.php +++ b/tests/cases/User/TestAuthorization.php @@ -6,7 +6,6 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\User; - use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Conf; use JKingWeb\Arsse\User; diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 85bb0eb9..dd63b4df 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -32,6 +32,8 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { public function approximateTime($exp, $act) { if (is_null($act)) { return null; + } elseif (is_null($exp)) { + return $act; } $target = Date::normalize($exp)->getTimeStamp(); $value = Date::normalize($act)->getTimeStamp(); diff --git a/tests/lib/Database/DriverSQLite3.php b/tests/lib/Database/DriverSQLite3.php index 04dc8872..1b76eea7 100644 --- a/tests/lib/Database/DriverSQLite3.php +++ b/tests/lib/Database/DriverSQLite3.php @@ -11,7 +11,7 @@ use JKingWeb\Arsse\Db\SQLite3\Driver; trait DriverSQLite3 { public function setUpDriver() { - if (!extension_loaded("sqlite3")) { + if (!Driver::requirementsMet()) { $this->markTestSkipped("SQLite extension not loaded"); } Arsse::$conf->dbSQLite3File = ":memory:"; diff --git a/tests/lib/Database/DriverSQLite3PDO.php b/tests/lib/Database/DriverSQLite3PDO.php new file mode 100644 index 00000000..9c52bd87 --- /dev/null +++ b/tests/lib/Database/DriverSQLite3PDO.php @@ -0,0 +1,24 @@ +markTestSkipped("PDO-SQLite extension not loaded"); + } + Arsse::$conf->dbSQLite3File = ":memory:"; + $this->drv = new PDODriver(); + } + + public function nextID(string $table): int { + return (int) $this->drv->query("SELECT (case when max(id) then max(id) else 0 end)+1 from $table")->getValue(); + } +} diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index 82267115..7ffae2d1 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -888,8 +888,8 @@ trait SeriesArticle { public function testFetchStarredCounts() { $exp1 = ['total' => 2, 'unread' => 1, 'read' => 1]; $exp2 = ['total' => 0, 'unread' => 0, 'read' => 0]; - $this->assertSame($exp1, Arsse::$db->articleStarred("john.doe@example.com")); - $this->assertSame($exp2, Arsse::$db->articleStarred("jane.doe@example.com")); + $this->assertEquals($exp1, Arsse::$db->articleStarred("john.doe@example.com")); + $this->assertEquals($exp2, Arsse::$db->articleStarred("jane.doe@example.com")); } public function testFetchStarredCountsWithoutAuthority() { diff --git a/tests/lib/Database/SeriesFeed.php b/tests/lib/Database/SeriesFeed.php index e605bd49..fcfaf6b6 100644 --- a/tests/lib/Database/SeriesFeed.php +++ b/tests/lib/Database/SeriesFeed.php @@ -256,9 +256,9 @@ trait SeriesFeed { } public function testListStaleFeeds() { - $this->assertSame([1,3,4], Arsse::$db->feedListStale()); + $this->assertEquals([1,3,4], Arsse::$db->feedListStale()); Arsse::$db->feedUpdate(3); Arsse::$db->feedUpdate(4); - $this->assertSame([1], Arsse::$db->feedListStale()); + $this->assertEquals([1], Arsse::$db->feedListStale()); } } diff --git a/tests/lib/Database/Setup.php b/tests/lib/Database/Setup.php index c2f3234b..c9b982f8 100644 --- a/tests/lib/Database/Setup.php +++ b/tests/lib/Database/Setup.php @@ -10,6 +10,7 @@ use JKingWeb\Arsse\User\Driver as UserDriver; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Conf; use JKingWeb\Arsse\User; +use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\Test\Database; use JKingWeb\Arsse\Db\Result; use Phake; @@ -90,8 +91,19 @@ trait Setup { $row = array_combine($cols, $row); foreach ($data as $index => $test) { foreach ($test as $col => $value) { - if ($types[$col]=="datetime") { - $test[$col] = $this->approximateTime($row[$col], $value); + switch ($types[$col]) { + case "datetime": + $test[$col] = $this->approximateTime($row[$col], $value); + break; + case "int": + $test[$col] = ValueInfo::normalize($value, ValueInfo::T_INT | ValueInfo::M_DROP | valueInfo::M_NULL); + break; + case "float": + $test[$col] = ValueInfo::normalize($value, ValueInfo::T_FLOAT | ValueInfo::M_DROP | valueInfo::M_NULL); + break; + case "bool": + $test[$col] = (int) ValueInfo::normalize($value, ValueInfo::T_BOOL | ValueInfo::M_DROP | valueInfo::M_NULL); + break; } } if ($row===$test) { diff --git a/tests/lib/Db/BindingTests.php b/tests/lib/Db/BindingTests.php deleted file mode 100644 index 5ba5ff3e..00000000 --- a/tests/lib/Db/BindingTests.php +++ /dev/null @@ -1,253 +0,0 @@ - null, - "integer" => null, - "float" => null, - "date" => null, - "time" => null, - "datetime" => null, - "binary" => null, - "string" => null, - "boolean" => null, - ]; - $this->checkBinding($input, $exp); - // types may also be strict (e.g. "strict integer") and never pass null to the database; this is useful for NOT NULL columns - // only null input should yield different results, so only this test has different expectations - $exp = [ - "null" => null, - "integer" => 0, - "float" => 0.0, - "date" => gmdate("Y-m-d", 0), - "time" => gmdate("H:i:s", 0), - "datetime" => gmdate("Y-m-d H:i:s", 0), - "binary" => "", - "string" => "", - "boolean" => 0, - ]; - $this->checkBinding($input, $exp, true); - } - - public function testBindTrue() { - $input = true; - $exp = [ - "null" => null, - "integer" => 1, - "float" => 1.0, - "date" => null, - "time" => null, - "datetime" => null, - "binary" => "1", - "string" => "1", - "boolean" => 1, - ]; - $this->checkBinding($input, $exp); - $this->checkBinding($input, $exp, true); - } - - public function testBindFalse() { - $input = false; - $exp = [ - "null" => null, - "integer" => 0, - "float" => 0.0, - "date" => null, - "time" => null, - "datetime" => null, - "binary" => "", - "string" => "", - "boolean" => 0, - ]; - $this->checkBinding($input, $exp); - $this->checkBinding($input, $exp, true); - } - - public function testBindInteger() { - $input = 2112; - $exp = [ - "null" => null, - "integer" => 2112, - "float" => 2112.0, - "date" => gmdate("Y-m-d", 2112), - "time" => gmdate("H:i:s", 2112), - "datetime" => gmdate("Y-m-d H:i:s", 2112), - "binary" => "2112", - "string" => "2112", - "boolean" => 1, - ]; - $this->checkBinding($input, $exp); - $this->checkBinding($input, $exp, true); - } - - public function testBindIntegerZero() { - $input = 0; - $exp = [ - "null" => null, - "integer" => 0, - "float" => 0.0, - "date" => gmdate("Y-m-d", 0), - "time" => gmdate("H:i:s", 0), - "datetime" => gmdate("Y-m-d H:i:s", 0), - "binary" => "0", - "string" => "0", - "boolean" => 0, - ]; - $this->checkBinding($input, $exp); - $this->checkBinding($input, $exp, true); - } - - public function testBindFloat() { - $input = 2112.0; - $exp = [ - "null" => null, - "integer" => 2112, - "float" => 2112.0, - "date" => gmdate("Y-m-d", 2112), - "time" => gmdate("H:i:s", 2112), - "datetime" => gmdate("Y-m-d H:i:s", 2112), - "binary" => "2112", - "string" => "2112", - "boolean" => 1, - ]; - $this->checkBinding($input, $exp); - $this->checkBinding($input, $exp, true); - } - - public function testBindFloatZero() { - $input = 0.0; - $exp = [ - "null" => null, - "integer" => 0, - "float" => 0.0, - "date" => gmdate("Y-m-d", 0), - "time" => gmdate("H:i:s", 0), - "datetime" => gmdate("Y-m-d H:i:s", 0), - "binary" => "0", - "string" => "0", - "boolean" => 0, - ]; - $this->checkBinding($input, $exp); - $this->checkBinding($input, $exp, true); - } - - public function testBindAsciiString() { - $input = "Random string"; - $exp = [ - "null" => null, - "integer" => 0, - "float" => 0.0, - "date" => null, - "time" => null, - "datetime" => null, - "binary" => $input, - "string" => $input, - "boolean" => 1, - ]; - $this->checkBinding($input, $exp); - $this->checkBinding($input, $exp, true); - } - - public function testBindUtf8String() { - $input = "é"; - $exp = [ - "null" => null, - "integer" => 0, - "float" => 0.0, - "date" => null, - "time" => null, - "datetime" => null, - "binary" => $input, - "string" => $input, - "boolean" => 1, - ]; - $this->checkBinding($input, $exp); - $this->checkBinding($input, $exp, true); - } - - public function testBindBinaryString() { - // FIXME: This test may be unreliable; SQLite happily stores invalid UTF-8 text as bytes untouched, but other engines probably don't do this - $input = chr(233); - $exp = [ - "null" => null, - "integer" => 0, - "float" => 0.0, - "date" => null, - "time" => null, - "datetime" => null, - "binary" => $input, - "string" => $input, - "boolean" => 1, - ]; - $this->checkBinding($input, $exp); - $this->checkBinding($input, $exp, true); - } - - public function testBindIso8601DateString() { - $input = "2017-01-09T13:11:17"; - $time = strtotime($input." UTC"); - $exp = [ - "null" => null, - "integer" => 2017, - "float" => 2017.0, - "date" => gmdate("Y-m-d", $time), - "time" => gmdate("H:i:s", $time), - "datetime" => gmdate("Y-m-d H:i:s", $time), - "binary" => $input, - "string" => $input, - "boolean" => 1, - ]; - $this->checkBinding($input, $exp); - $this->checkBinding($input, $exp, true); - } - - public function testBindArbitraryDateString() { - $input = "Today"; - $time = date_create($input, new \DateTimezone("UTC"))->getTimestamp(); - $exp = [ - "null" => null, - "integer" => 0, - "float" => 0.0, - "date" => gmdate("Y-m-d", $time), - "time" => gmdate("H:i:s", $time), - "datetime" => gmdate("Y-m-d H:i:s", $time), - "binary" => $input, - "string" => $input, - "boolean" => 1, - ]; - $this->checkBinding($input, $exp); - $this->checkBinding($input, $exp, true); - } - - public function testBindMutableDateObject($class = '\DateTime') { - $input = new $class("Noon Today"); - $time = $input->getTimestamp(); - $exp = [ - "null" => null, - "integer" => $time, - "float" => (float) $time, - "date" => gmdate("Y-m-d", $time), - "time" => gmdate("H:i:s", $time), - "datetime" => gmdate("Y-m-d H:i:s", $time), - "binary" => gmdate("Y-m-d H:i:s", $time), - "string" => gmdate("Y-m-d H:i:s", $time), - "boolean" => 1, - ]; - $this->checkBinding($input, $exp); - $this->checkBinding($input, $exp, true); - } - - public function testBindImmutableDateObject() { - $this->testBindMutableDateObject('\DateTimeImmutable'); - } -} diff --git a/tests/lib/PDOTest.php b/tests/lib/PDOTest.php new file mode 100644 index 00000000..3013734f --- /dev/null +++ b/tests/lib/PDOTest.php @@ -0,0 +1,23 @@ + $v) { + if (is_array($v)) { + $value[$k] = $this->v($v); + } elseif (is_int($v) || is_float($v)) { + $value[$k] = (string) $v; + } + } + return $value; + } +} \ No newline at end of file diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 8ffa2b62..0c814a8b 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -52,6 +52,12 @@ cases/Db/SQLite3/TestCreation.php cases/Db/SQLite3/TestDriver.php cases/Db/SQLite3/TestUpdate.php + + cases/Db/SQLite3PDO/TestResult.php + cases/Db/SQLite3PDO/TestStatement.php + cases/Db/SQLite3PDO/TestCreation.php + cases/Db/SQLite3PDO/TestDriver.php + cases/Db/SQLite3PDO/TestUpdate.php cases/Db/SQLite3/Database/TestMiscellany.php @@ -64,15 +70,28 @@ cases/Db/SQLite3/Database/TestArticle.php cases/Db/SQLite3/Database/TestLabel.php cases/Db/SQLite3/Database/TestCleanup.php + + cases/Db/SQLite3PDO/Database/TestMiscellany.php + cases/Db/SQLite3PDO/Database/TestMeta.php + cases/Db/SQLite3PDO/Database/TestUser.php + cases/Db/SQLite3PDO/Database/TestSession.php + cases/Db/SQLite3PDO/Database/TestFolder.php + cases/Db/SQLite3PDO/Database/TestFeed.php + cases/Db/SQLite3PDO/Database/TestSubscription.php + cases/Db/SQLite3PDO/Database/TestArticle.php + cases/Db/SQLite3PDO/Database/TestLabel.php + cases/Db/SQLite3PDO/Database/TestCleanup.php cases/REST/NextCloudNews/TestVersions.php cases/REST/NextCloudNews/TestV1_2.php + cases/REST/NextCloudNews/PDO/TestV1_2.php cases/REST/TinyTinyRSS/TestAPI.php cases/REST/TinyTinyRSS/TestIcon.php + cases/REST/TinyTinyRSS/PDO/TestAPI.php