diff --git a/.php_cs.dist b/.php_cs.dist index 90db01f..49435d7 100644 --- a/.php_cs.dist +++ b/.php_cs.dist @@ -17,6 +17,7 @@ $paths = [ $rules = [ '@PSR2' => true, 'braces' => ['position_after_functions_and_oop_constructs' => "same"], + 'function_declaration' => ['closure_function_spacing' => "none"], ]; $finder = \PhpCsFixer\Finder::create(); diff --git a/CHANGELOG b/CHANGELOG index 5601723..5bde4c4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,15 @@ +Version 0.6.0 (????-??-??) +========================== + +New features: +- Support for PostgreSQL databases + +Bug fixes: +- Use a general-purpose Unicode collation with SQLite databases + +Changes: +- Improve performance of common database queries by 80-90% + Version 0.5.1 (2018-11-10) ========================== diff --git a/README.md b/README.md index c8ae313..afd48f0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The Arsse is a news aggregator server which implements multiple synchronization At present the software should be considered in an "alpha" state: though its core subsystems are covered by unit tests and should be free of major bugs, not everything has been rigorously tested. Additionally, many features one would expect from other similar software have yet to be implemented. Areas of future work include: -- Support for more database engines (PostgreSQL, MySQL, MariaDB) +- Support for more database engines (MySQL, MariaDB) - Providing more sync protocols (Google Reader, Fever, others) - Better packaging and configuration samples @@ -16,7 +16,9 @@ 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) or [pdo_sqlite](http://ca1.php.net/manual/en/ref.pdo-sqlite.php) + - Either of: + - [sqlite3](http://php.net/manual/en/book.sqlite3.php) or [pdo_sqlite](http://ca1.php.net/manual/en/ref.pdo-sqlite.php) for SQLite databases + - [pgsql](http://php.net/manual/en/book.pgsql.php) or [pdo_pgsql](http://ca1.php.net/manual/en/ref.pdo-pgsql.php) for PostgreSQL 9.1 or later databases - Privileges to create and run daemon processes on the server ## Installation @@ -69,6 +71,10 @@ The Arsse is made available under the permissive MIT license. See the `LICENSE` Please refer to `CONTRIBUTING.md` for guidelines on contributing code to The Arsse. +## Database compatibility notes + +Functionally there is no reason to prefer either SQLite or PostgreSQL over the other. SQLite, however, is significantly simpler to set up in most cases, requiring only read and write access to a containing directory in order to function. On the other hand PostgreSQL may perform better than SQLite when serving hundreds of users or more, but this has not been tested. + ## Protocol compatibility notes ### General diff --git a/RoboFile.php b/RoboFile.php index 65d0363..e73d3e4 100644 --- a/RoboFile.php +++ b/RoboFile.php @@ -50,6 +50,21 @@ class RoboFile extends \Robo\Tasks { * recommended if debugging facilities are not otherwise needed. */ public function coverage(array $args): Result { + // run tests with code coverage reporting enabled + $exec = $this->findCoverageEngine(); + return $this->runTests($exec, "coverage", array_merge(["--coverage-html", self::BASE_TEST."coverage"], $args)); + } + + /** Produces a code coverage report, with redundant tests + * + * Depending on the environment, some tests that normally provide + * coverage may be skipped, while working alternatives are normally + * suppressed for reasons of time. This coverage report will try to + * run all tests which may cover code. + * + * See also help for the "coverage" task for more details. + */ + public function coverageFull(array $args): Result { // run tests with code coverage reporting enabled $exec = $this->findCoverageEngine(); return $this->runTests($exec, "typical", array_merge(["--coverage-html", self::BASE_TEST."coverage"], $args)); @@ -66,13 +81,16 @@ class RoboFile extends \Robo\Tasks { } protected function findCoverageEngine(): string { - $null = null; - $code = 0; - exec("phpdbg --version", $null, $code); - if (!$code) { - return "phpdbg -qrr"; + if ($this->isWindows()) { + $dbg = dirname(\PHP_BINARY)."\\phpdbg.exe"; + $dbg = file_exists($dbg) ? $dbg : ""; } else { - return "php"; + $dbg = `which phpdbg`; + } + if ($dbg) { + return escapeshellarg($dbg)." -qrr"; + } else { + return escapeshellarg(\PHP_BINARY); } } @@ -88,6 +106,9 @@ class RoboFile extends \Robo\Tasks { case "quick": $set = ["--exclude-group", "optional,slow"]; break; + case "coverage": + $set = ["--exclude-group", "optional,coverageOptional"]; + break; case "full": $set = []; break; @@ -96,9 +117,8 @@ class RoboFile extends \Robo\Tasks { } $execpath = realpath(self::BASE."vendor-bin/phpunit/vendor/phpunit/phpunit/phpunit"); $confpath = realpath(self::BASE_TEST."phpunit.xml"); - $blackhole = $this->isWindows() ? "nul" : "/dev/null"; $this->taskServer(8000)->host("localhost")->dir(self::BASE_TEST."docroot")->rawArg("-n")->arg(self::BASE_TEST."server.php")->background()->run(); - return $this->taskExec($executor)->arg($execpath)->option("-c", $confpath)->args(array_merge($set, $args))->rawArg("2>$blackhole")->run(); + return $this->taskExec($executor)->arg($execpath)->option("-c", $confpath)->args(array_merge($set, $args))->run(); } /** Packages a given commit of the software into a release tarball diff --git a/UPGRADING b/UPGRADING index 160574f..3abe6ee 100644 --- a/UPGRADING +++ b/UPGRADING @@ -9,6 +9,12 @@ When upgrading between any two versions of The Arsse, the following are usually - If installing from source, update dependencies with `composer install -o --no-dev` +Upgrading from 0.5.1 to 0.6.0 +============================= + +- The database schema has changed from rev3 to rev4; if upgrading the database manually, apply the 3.sql file + + Upgrading from 0.2.1 to 0.3.0 ============================= diff --git a/composer.json b/composer.json index aa4ac4a..5f943d9 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,8 @@ }, "autoload-dev": { "psr-4": { - "JKingWeb\\Arsse\\Test\\": "tests/lib/" + "JKingWeb\\Arsse\\Test\\": "tests/lib/", + "JKingWeb\\Arsse\\TestCase\\": "tests/cases/" } } } diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 38ff02b..4eee0ff 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -26,6 +26,7 @@ abstract class AbstractException extends \Exception { "Db/Exception.fileUnwritable" => 10205, "Db/Exception.fileUncreatable" => 10206, "Db/Exception.fileCorrupt" => 10207, + "Db/Exception.connectionFailure" => 10208, "Db/Exception.updateTooNew" => 10211, "Db/Exception.updateManual" => 10212, "Db/Exception.updateManualOnly" => 10213, diff --git a/lib/CLI.php b/lib/CLI.php index 0ad8e53..7f9deac 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -81,11 +81,16 @@ USAGE_TEXT; return $this->userManage($args); } } catch (AbstractException $e) { - fwrite(STDERR, $e->getMessage().\PHP_EOL); + $this->logError($e->getMessage()); return $e->getCode(); } } + /** @codeCoverageIgnore */ + protected function logError(string $msg) { + fwrite(STDERR,$msg.\PHP_EOL); + } + /** @codeCoverageIgnore */ protected function getService(): Service { return new Service; diff --git a/lib/Conf.php b/lib/Conf.php index 3d4eeb2..4572cd3 100644 --- a/lib/Conf.php +++ b/lib/Conf.php @@ -19,12 +19,30 @@ class Conf { public $dbDriver = Db\SQLite3\Driver::class; /** @var boolean Whether to attempt to automatically update the database when updated to a new version with schema changes */ public $dbAutoUpdate = true; + /** @var float Number of seconds to wait before returning a timeout error when connecting to a database (zero waits forever; not applicable to SQLite) */ + public $dbTimeoutConnect = 5.0; + /** @var float Number of seconds to wait before returning a timeout error when executing a database operation (zero waits forever; not applicable to SQLite) */ + public $dbTimeoutExec = 0.0; /** @var string|null Full path and file name of SQLite database (if using SQLite) */ public $dbSQLite3File = null; /** @var string Encryption key to use for SQLite database (if using a version of SQLite with SEE) */ public $dbSQLite3Key = ""; - /** @var integer Number of seconds for SQLite to wait before returning a timeout error when writing to the database */ - public $dbSQLite3Timeout = 60; + /** @var float Number of seconds for SQLite to wait before returning a timeout error when trying to acquire a write lock on the database (zero does not wait) */ + public $dbSQLite3Timeout = 60.0; + /** @var string Host name, address, or socket path of PostgreSQL database server (if using PostgreSQL) */ + public $dbPostgreSQLHost = ""; + /** @var string Log-in user name for PostgreSQL database server (if using PostgreSQL) */ + public $dbPostgreSQLUser = "arsse"; + /** @var string Log-in password for PostgreSQL database server (if using PostgreSQL) */ + public $dbPostgreSQLPass = ""; + /** @var integer Listening port for PostgreSQL database server (if using PostgreSQL over TCP) */ + public $dbPostgreSQLPort = 5432; + /** @var string Database name on PostgreSQL database server (if using PostgreSQL) */ + public $dbPostgreSQLDb = "arsse"; + /** @var string Schema name in PostgreSQL database (if using PostgreSQL) */ + public $dbPostgreSQLSchema = ""; + /** @var string Service file entry to use (if using PostgreSQL); if using a service entry all above parameters except schema are ignored */ + public $dbPostgreSQLService = ""; /** @var string Class of the user management driver in use (Internal by default) */ public $userDriver = User\Internal\Driver::class; diff --git a/lib/Database.php b/lib/Database.php index 7182c6f..2cb5514 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -7,19 +7,15 @@ declare(strict_types=1); namespace JKingWeb\Arsse; use JKingWeb\DrUUID\UUID; +use JKingWeb\Arsse\Db\Statement; use JKingWeb\Arsse\Misc\Query; use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; class Database { - const SCHEMA_VERSION = 3; + const SCHEMA_VERSION = 4; const LIMIT_ARTICLES = 50; - // articleList verbosity levels - const LIST_MINIMAL = 0; // only that metadata which is required for context matching - const LIST_CONSERVATIVE = 1; // base metadata plus anything that is not potentially large text - const LIST_TYPICAL = 2; // conservative, with the addition of content - const LIST_FULL = 3; // all possible fields /** @var Db\Driver */ public $db; @@ -84,13 +80,26 @@ class Database { protected function generateIn(array $values, string $type): array { $out = [ - [], // query clause + "", // query clause [], // binding types ]; - // the query clause is just a series of question marks separated by commas - $out[0] = implode(",", array_fill(0, sizeof($values), "?")); - // the binding types are just a repetition of the supplied type - $out[1] = array_fill(0, sizeof($values), $type); + if (sizeof($values)) { + // the query clause is just a series of question marks separated by commas + $out[0] = implode(",", array_fill(0, sizeof($values), "?")); + // the binding types are just a repetition of the supplied type + $out[1] = array_fill(0, sizeof($values), $type); + } else { + // if the set is empty, some databases require a query which returns an empty set + $standin = [ + 'string' => "''", + 'binary' => "''", + 'datetime' => "''", + 'integer' => "1", + 'boolean' => "1", + 'float' => "1.0", + ][Statement::TYPES[$type] ?? "string"]; + $out[0] = "select $standin where 1 = 0"; + } return $out; } @@ -182,7 +191,7 @@ class Database { $id = UUID::mint()->hex; $expires = Date::add(Arsse::$conf->userSessionTimeout); // save the session to the database - $this->db->prepare("INSERT INTO arsse_sessions(id,expires,user) values(?,?,?)", "str", "datetime", "str")->run($id, $expires, $user); + $this->db->prepare("INSERT INTO arsse_sessions(id,expires,\"user\") values(?,?,?)", "str", "datetime", "str")->run($id, $expires, $user); // return the ID return $id; } @@ -193,12 +202,12 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } // delete the session and report success. - return (bool) $this->db->prepare("DELETE FROM arsse_sessions where id = ? and user = ?", "str", "str")->run($id, $user)->changes(); + return (bool) $this->db->prepare("DELETE FROM arsse_sessions where id = ? and \"user\" = ?", "str", "str")->run($id, $user)->changes(); } public function sessionResume(string $id): array { $maxAge = Date::sub(Arsse::$conf->userSessionLifetime); - $out = $this->db->prepare("SELECT id,created,expires,user from arsse_sessions where id = ? and expires > CURRENT_TIMESTAMP and created > ?", "str", "datetime")->run($id, $maxAge)->getRow(); + $out = $this->db->prepare("SELECT id,created,expires,\"user\" from arsse_sessions where id = ? and expires > CURRENT_TIMESTAMP and created > ?", "str", "datetime")->run($id, $maxAge)->getRow(); // if the session does not exist or is expired, throw an exception if (!$out) { throw new User\ExceptionSession("invalid", $id); @@ -371,13 +380,13 @@ class Database { // SQL will happily accept duplicates (null is not unique), so we must do this check ourselves $p = $this->db->prepare( "WITH RECURSIVE - target as (select ? as user, ? as source, ? as dest, ? as rename), - folders as (SELECT id from arsse_folders join target on owner = user and coalesce(parent,0) = source union select arsse_folders.id as id from arsse_folders join folders on arsse_folders.parent=folders.id) + target as (select ? as userid, ? as source, ? as dest, ? as rename), + folders as (SELECT id from arsse_folders join target on owner = userid and coalesce(parent,0) = source union select arsse_folders.id as id from arsse_folders join folders on arsse_folders.parent=folders.id) ". "SELECT - ((select dest from target) is null or exists(select id from arsse_folders join target on owner = user and coalesce(id,0) = coalesce(dest,0))) as extant, - not exists(select id from folders where id = coalesce((select dest from target),0)) as valid, - not exists(select id from arsse_folders join target on coalesce(parent,0) = coalesce(dest,0) and name = coalesce((select rename from target),(select name from arsse_folders join target on id = source))) as available + case when ((select dest from target) is null or exists(select id from arsse_folders join target on owner = userid and coalesce(id,0) = coalesce(dest,0))) then 1 else 0 end as extant, + case when not exists(select id from folders where id = coalesce((select dest from target),0)) then 1 else 0 end as valid, + case when not exists(select id from arsse_folders join target on coalesce(parent,0) = coalesce(dest,0) and name = coalesce((select rename from target),(select name from arsse_folders join target on id = source))) then 1 else 0 end as available ", "str", "strict int", @@ -409,7 +418,7 @@ class Database { // make sure that a folder with the same prospective name and parent does not already exist: if the parent is null, // SQL will happily accept duplicates (null is not unique), so we must do this check ourselves $parent = $parent ? $parent : null; - if ($this->db->prepare("SELECT exists(select id from arsse_folders where coalesce(parent,0) = ? and name = ?)", "strict int", "str")->run($parent, $name)->getValue()) { + if ($this->db->prepare("SELECT count(*) from arsse_folders where coalesce(parent,0) = ? and name = ?", "strict int", "str")->run($parent, $name)->getValue()) { throw new Db\ExceptionInput("constraintViolation", ["action" => $this->caller(), "field" => "name"]); } return true; @@ -462,15 +471,16 @@ class Database { coalesce(arsse_subscriptions.title, arsse_feeds.title) as title, (SELECT count(*) from arsse_articles where feed = arsse_subscriptions.feed) - (SELECT count(*) from arsse_marks where subscription = arsse_subscriptions.id and read = 1) as unread from arsse_subscriptions - join user on user = owner + join userdata on userid = owner join arsse_feeds on feed = arsse_feeds.id left join topmost on folder=f_id" ); - $q->setOrder("pinned desc, title collate nocase"); + $nocase = $this->db->sqlToken("nocase"); + $q->setOrder("pinned desc, coalesce(arsse_subscriptions.title, arsse_feeds.title) collate $nocase"); // define common table expressions - $q->setCTE("user(user)", "SELECT ?", "str", $user); // the subject user; this way we only have to pass it to prepare() once + $q->setCTE("userdata(userid)", "SELECT ?", "str", $user); // the subject user; this way we only have to pass it to prepare() once // topmost folders belonging to the user - $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders join user on owner = user where parent is null union select id,top from arsse_folders join topmost on parent=f_id"); + $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders join userdata on owner = userid where parent is null union select id,top from arsse_folders join topmost on parent=f_id"); if ($id) { // this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder // if an ID is specified, add a suitable WHERE condition and bindings @@ -795,73 +805,102 @@ class Database { )->run($feedID, $ids, $hashesUT, $hashesUC, $hashesTC); } - protected function articleQuery(string $user, Context $context, array $extraColumns = []): Query { - $extraColumns = implode(",", $extraColumns); - if (strlen($extraColumns)) { - $extraColumns .= ","; + protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query { + $greatest = $this->db->sqlToken("greatest"); + // prepare the output column list + $colDefs = [ + 'id' => "arsse_articles.id", + 'edition' => "latest_editions.edition", + 'url' => "arsse_articles.url", + 'title' => "arsse_articles.title", + 'author' => "arsse_articles.author", + 'content' => "arsse_articles.content", + 'guid' => "arsse_articles.guid", + 'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash", + 'subscription' => "arsse_subscriptions.id", + 'feed' => "arsse_subscriptions.feed", + 'starred' => "coalesce(arsse_marks.starred,0)", + 'unread' => "abs(coalesce(arsse_marks.read,0) - 1)", + 'note' => "coalesce(arsse_marks.note,'')", + 'published_date' => "arsse_articles.published", + 'edited_date' => "arsse_articles.edited", + 'modified_date' => "arsse_articles.modified", + 'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(arsse_label_members.modified, '0001-01-01 00:00:00'))", + 'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)", + 'media_url' => "arsse_enclosures.url", + 'media_type' => "arsse_enclosures.type", + + ]; + if (!$cols) { + // if no columns are specified return a count + $columns = "count(distinct arsse_articles.id) as count"; + } else { + $columns = []; + foreach ($cols as $col) { + $col = trim(strtolower($col)); + if (!isset($colDefs[$col])) { + continue; + } + $columns[] = $colDefs[$col]." as ".$col; + } + $columns = implode(",", $columns); } + // define the basic query, to which we add lots of stuff where necessary $q = new Query( - "SELECT - $extraColumns - arsse_articles.id as id, - arsse_articles.feed as feed, - arsse_articles.modified as modified_date, - max( - arsse_articles.modified, - coalesce((select modified from arsse_marks where article = arsse_articles.id and subscription in (select sub from subscribed_feeds)),''), - coalesce((select modified from arsse_label_members where article = arsse_articles.id and subscription in (select sub from subscribed_feeds)),'') - ) as marked_date, - NOT (select count(*) from arsse_marks where article = arsse_articles.id and read = 1 and subscription in (select sub from subscribed_feeds)) as unread, - (select count(*) from arsse_marks where article = arsse_articles.id and starred = 1 and subscription in (select sub from subscribed_feeds)) as starred, - (select max(id) from arsse_editions where article = arsse_articles.id) as edition, - subscribed_feeds.sub as subscription - FROM arsse_articles" + "SELECT + $columns + from arsse_articles + join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed and arsse_subscriptions.owner = ? + join arsse_feeds on arsse_subscriptions.feed = arsse_feeds.id + left join arsse_marks on arsse_marks.subscription = arsse_subscriptions.id and arsse_marks.article = arsse_articles.id + left join arsse_enclosures on arsse_enclosures.article = arsse_articles.id + left join arsse_label_members on arsse_label_members.subscription = arsse_subscriptions.id and arsse_label_members.article = arsse_articles.id and arsse_label_members.assigned = 1 + left join arsse_labels on arsse_labels.owner = arsse_subscriptions.owner and arsse_label_members.label = arsse_labels.id", + ["str"], + [$user] ); + $q->setCTE("latest_editions(article,edition)", "SELECT article,max(id) from arsse_editions group by article", [], [], "join latest_editions on arsse_articles.id = latest_editions.article"); + if ($cols) { + // if there are no output columns requested we're getting a count and should not group, but otherwise we should + $q->setGroup("arsse_articles.id", "arsse_marks.note", "arsse_enclosures.url", "arsse_enclosures.type", "arsse_subscriptions.title", "arsse_feeds.title", "arsse_subscriptions.id", "arsse_marks.modified", "arsse_label_members.modified", "arsse_marks.read", "arsse_marks.starred", "latest_editions.edition"); + } $q->setLimit($context->limit, $context->offset); - $q->setCTE("user(user)", "SELECT ?", "str", $user); if ($context->subscription()) { // if a subscription is specified, make sure it exists - $id = $this->subscriptionValidateId($user, $context->subscription)['feed']; - // add a basic CTE that will join in only the requested subscription - $q->setCTE("subscribed_feeds(id,sub)", "SELECT ?,?", ["int","int"], [$id,$context->subscription], "join subscribed_feeds on feed = subscribed_feeds.id"); + $this->subscriptionValidateId($user, $context->subscription); + // filter for the subscription + $q->setWhere("arsse_subscriptions.id = ?", "int", $context->subscription); } elseif ($context->folder()) { // if a folder is specified, make sure it exists $this->folderValidateId($user, $context->folder); // if it does exist, add a common table expression to list it and its children so that we select from the entire subtree $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder); - // add another CTE for the subscriptions within the folder - $q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user = owner join folders on arsse_subscriptions.folder = folders.folder", [], [], "join subscribed_feeds on feed = subscribed_feeds.id"); + // limit subscriptions to the listed folders + $q->setWhere("arsse_subscriptions.folder in (select folder from folders)"); } elseif ($context->folderShallow()) { // if a shallow folder is specified, make sure it exists $this->folderValidateId($user, $context->folderShallow); - // if it does exist, add a CTE with only its subscriptions (and not those of its descendents) - $q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user = owner and coalesce(folder,0) = ?", "strict int", $context->folderShallow, "join subscribed_feeds on feed = subscribed_feeds.id"); - } else { - // otherwise add a CTE for all the user's subscriptions - $q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user = owner", [], [], "join subscribed_feeds on feed = subscribed_feeds.id"); + // if it does exist, filter for that folder only + $q->setWhere("coalesce(arsse_subscriptions.folder,0) = ?", "int", $context->folderShallow); } if ($context->edition()) { - // if an edition is specified, filter for its previously identified article - $q->setWhere("arsse_articles.id = (select article from arsse_editions where id = ?)", "int", $context->edition); + // if an edition is specified, first validate it, then filter for it + $this->articleValidateEdition($user, $context->edition); + $q->setWhere("latest_editions.edition = ?", "int", $context->edition); } elseif ($context->article()) { - // if an article is specified, filter for it (it has already been validated above) + // if an article is specified, first validate it, then filter for it + $this->articleValidateId($user, $context->article); $q->setWhere("arsse_articles.id = ?", "int", $context->article); } if ($context->editions()) { - // if multiple specific editions have been requested, prepare a CTE to list them and their articles + // if multiple specific editions have been requested, filter against the list if (!$context->editions) { throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element } elseif (sizeof($context->editions) > self::LIMIT_ARTICLES) { throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore } list($inParams, $inTypes) = $this->generateIn($context->editions, "int"); - $q->setCTE( - "requested_articles(id,edition)", - "SELECT article,id as edition from arsse_editions where edition in ($inParams)", - $inTypes, - $context->editions - ); - $q->setWhere("arsse_articles.id in (select id from requested_articles)"); + $q->setWhere("latest_editions.edition in ($inParams)", $inTypes, $context->editions); } elseif ($context->articles()) { // if multiple specific articles have been requested, prepare a CTE to list them and their articles if (!$context->articles) { @@ -870,21 +909,13 @@ class Database { throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore } list($inParams, $inTypes) = $this->generateIn($context->articles, "int"); - $q->setCTE( - "requested_articles(id,edition)", - "SELECT id,(select max(id) from arsse_editions where article = arsse_articles.id) as edition from arsse_articles where arsse_articles.id in ($inParams)", - $inTypes, - $context->articles - ); - $q->setWhere("arsse_articles.id in (select id from requested_articles)"); - } else { - // if neither list is specified, mock an empty table - $q->setCTE("requested_articles(id,edition)", "SELECT 'empty','table' where 1 = 0"); + $q->setWhere("arsse_articles.id in ($inParams)", $inTypes, $context->articles); } // filter based on label by ID or name if ($context->labelled()) { // any label (true) or no label (false) - $q->setWhere((!$context->labelled ? "not " : "")."exists(select article from arsse_label_members where assigned = 1 and article = arsse_articles.id and subscription in (select sub from subscribed_feeds))"); + $isOrIsNot = (!$context->labelled ? "is" : "is not"); + $q->setWhere("arsse_labels.id $isOrIsNot null"); } elseif ($context->label() || $context->labelName()) { // specific label ID or name if ($context->label()) { @@ -892,7 +923,7 @@ class Database { } else { $id = $this->labelValidateId($user, $context->labelName, true)['id']; } - $q->setWhere("exists(select article from arsse_label_members where assigned = 1 and article = arsse_articles.id and label = ?)", "int", $id); + $q->setWhere("arsse_labels.id = ?", "int", $id); } // filter based on article or edition offset if ($context->oldestArticle()) { @@ -902,40 +933,41 @@ class Database { $q->setWhere("arsse_articles.id <= ?", "int", $context->latestArticle); } if ($context->oldestEdition()) { - $q->setWhere("edition >= ?", "int", $context->oldestEdition); + $q->setWhere("latest_editions.edition >= ?", "int", $context->oldestEdition); } if ($context->latestEdition()) { - $q->setWhere("edition <= ?", "int", $context->latestEdition); + $q->setWhere("latest_editions.edition <= ?", "int", $context->latestEdition); } // filter based on time at which an article was changed by feed updates (modified), or by user action (marked) if ($context->modifiedSince()) { - $q->setWhere("modified_date >= ?", "datetime", $context->modifiedSince); + $q->setWhere("arsse_articles.modified >= ?", "datetime", $context->modifiedSince); } if ($context->notModifiedSince()) { - $q->setWhere("modified_date <= ?", "datetime", $context->notModifiedSince); + $q->setWhere("arsse_articles.modified <= ?", "datetime", $context->notModifiedSince); } if ($context->markedSince()) { - $q->setWhere("marked_date >= ?", "datetime", $context->markedSince); + $q->setWhere($colDefs['marked_date']." >= ?", "datetime", $context->markedSince); } if ($context->notMarkedSince()) { - $q->setWhere("marked_date <= ?", "datetime", $context->notMarkedSince); + $q->setWhere($colDefs['marked_date']." <= ?", "datetime", $context->notMarkedSince); } // filter for un/read and un/starred status if specified if ($context->unread()) { - $q->setWhere("unread = ?", "bool", $context->unread); + $q->setWhere("coalesce(arsse_marks.read,0) = ?", "bool", !$context->unread); } if ($context->starred()) { - $q->setWhere("starred = ?", "bool", $context->starred); + $q->setWhere("coalesce(arsse_marks.starred,0) = ?", "bool", $context->starred); } // filter based on whether the article has a note if ($context->annotated()) { - $q->setWhere((!$context->annotated ? "not " : "")."exists(select modified from arsse_marks where article = arsse_articles.id and note <> '' and subscription in (select sub from subscribed_feeds))"); + $comp = ($context->annotated) ? "<>" : "="; + $q->setWhere("coalesce(arsse_marks.note,'') $comp ''"); } // return the query return $q; } - protected function articleChunk(Context $context): array { + protected function contextChunk(Context $context): array { $exception = ""; if ($context->editions()) { // editions take precedence over articles @@ -959,13 +991,13 @@ class Database { } } - public function articleList(string $user, Context $context = null, int $fields = self::LIST_FULL): Db\Result { + public function articleList(string $user, Context $context = null, array $fields = ["id"]): Db\Result { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $context = $context ?? new Context; // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result - if ($contexts = $this->articleChunk($context)) { + if ($contexts = $this->contextChunk($context)) { $out = []; $tr = $this->begin(); foreach ($contexts as $context) { @@ -974,46 +1006,9 @@ class Database { $tr->commit(); return new Db\ResultAggregate(...$out); } else { - $columns = []; - switch ($fields) { - // NOTE: the cases all cascade into each other: a given verbosity level is always a superset of the previous one - case self::LIST_FULL: // everything - $columns = array_merge($columns, [ - "(select note from arsse_marks where article = arsse_articles.id and subscription in (select sub from subscribed_feeds)) as note", - ]); - // no break - case self::LIST_TYPICAL: // conservative, plus content - $columns = array_merge($columns, [ - "content", - "arsse_enclosures.url as media_url", // enclosures are potentially large due to data: URLs - "arsse_enclosures.type as media_type", // FIXME: enclosures should eventually have their own fetch method - ]); - // no break - case self::LIST_CONSERVATIVE: // base metadata, plus anything that is not likely to be large text - $columns = array_merge($columns, [ - "arsse_articles.url as url", - "arsse_articles.title as title", - "(select coalesce(arsse_subscriptions.title,arsse_feeds.title) from arsse_feeds join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id where arsse_feeds.id = arsse_articles.feed) as subscription_title", - "author", - "guid", - "published as published_date", - "edited as edited_date", - "url_title_hash||':'||url_content_hash||':'||title_content_hash as fingerprint", - ]); - // no break - case self::LIST_MINIMAL: // base metadata (always included: required for context matching) - $columns = array_merge($columns, [ - // id, subscription, feed, modified_date, marked_date, unread, starred, edition - "edited as edited_date", - ]); - break; - default: - throw new Exception("constantUnknown", $fields); - } - $q = $this->articleQuery($user, $context, $columns); - $q->setOrder("edited_date".($context->reverse ? " desc" : "")); - $q->setOrder("edition".($context->reverse ? " desc" : "")); - $q->setJoin("left join arsse_enclosures on arsse_enclosures.article = arsse_articles.id"); + $q = $this->articleQuery($user, $context, $fields); + $q->setOrder("arsse_articles.edited".($context->reverse ? " desc" : "")); + $q->setOrder("latest_editions.edition".($context->reverse ? " desc" : "")); // perform the query and return results return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } @@ -1025,7 +1020,7 @@ class Database { } $context = $context ?? new Context; // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result - if ($contexts = $this->articleChunk($context)) { + if ($contexts = $this->contextChunk($context)) { $out = 0; $tr = $this->begin(); foreach ($contexts as $context) { @@ -1034,9 +1029,7 @@ class Database { $tr->commit(); return $out; } else { - $q = $this->articleQuery($user, $context); - $q->pushCTE("selected_articles"); - $q->setBody("SELECT count(*) from selected_articles"); + $q = $this->articleQuery($user, $context, []); return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } } @@ -1045,9 +1038,17 @@ class Database { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } + $data = [ + 'read' => $data['read'] ?? null, + 'starred' => $data['starred'] ?? null, + 'note' => $data['note'] ?? null, + ]; + if (!isset($data['read']) && !isset($data['starred']) && !isset($data['note'])) { + return 0; + } $context = $context ?? new Context; // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result - if ($contexts = $this->articleChunk($context)) { + if ($contexts = $this->contextChunk($context)) { $out = 0; $tr = $this->begin(); foreach ($contexts as $context) { @@ -1056,63 +1057,69 @@ class Database { $tr->commit(); return $out; } else { - // sanitize input - $values = [ - isset($data['read']) ? $data['read'] : null, - isset($data['starred']) ? $data['starred'] : null, - isset($data['note']) ? $data['note'] : null, - ]; - // the two queries we want to execute to make the requested changes - $queries = [ - "UPDATE arsse_marks - set - read = case when (select honour_read from target_articles where target_articles.id = article) = 1 then (select read from target_values) else read end, - starred = coalesce((select starred from target_values),starred), - note = coalesce((select note from target_values),note), - modified = CURRENT_TIMESTAMP - WHERE - subscription in (select sub from subscribed_feeds) - and article in (select id from target_articles where to_insert = 0 and (honour_read = 1 or honour_star = 1 or (select note from target_values) is not null))", - "INSERT INTO arsse_marks(subscription,article,read,starred,note) - select - (select id from arsse_subscriptions join user on user = owner where arsse_subscriptions.feed = target_articles.feed), - id, - coalesce((select read from target_values) * honour_read,0), - coalesce((select starred from target_values),0), - coalesce((select note from target_values),'') - from target_articles where to_insert = 1 and (honour_read = 1 or honour_star = 1 or coalesce((select note from target_values),'') <> '')" - ]; - $out = 0; - // wrap this UPDATE and INSERT together into a transaction $tr = $this->begin(); - // if an edition context is specified, make sure it's valid - if ($context->edition()) { - // make sure the edition exists - $edition = $this->articleValidateEdition($user, $context->edition); - // if the edition is not the latest, do not mark the read flag - if (!$edition['current']) { - $values[0] = null; - } - } elseif ($context->article()) { - // otherwise if an article context is specified, make sure it's valid - $this->articleValidateId($user, $context->article); + $out = 0; + if ($data['read'] || $data['starred'] || strlen($data['note'] ?? "")) { + // first prepare a query to insert any missing marks rows for the articles we want to mark + // but only insert new mark records if we're setting at least one "positive" mark + $q = $this->articleQuery($user, $context, ["id", "subscription"]); + $q->setWhere("arsse_marks.starred is null"); // null means there is no marks row for the article + $q->pushCTE("missing_marks(article,subscription)"); + $q->setBody("INSERT INTO arsse_marks(article,subscription) SELECT article,subscription from missing_marks"); + $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } - // execute each query in sequence - foreach ($queries as $query) { - // first build the query which will select the target articles; we will later turn this into a CTE for the actual query that manipulates the articles - $q = $this->articleQuery($user, $context, [ - "(not exists(select article from arsse_marks where article = arsse_articles.id and subscription in (select sub from subscribed_feeds))) as to_insert", - "((select read from target_values) is not null and (select read from target_values) <> (coalesce((select read from arsse_marks where article = arsse_articles.id and subscription in (select sub from subscribed_feeds)),0)) and (not exists(select * from requested_articles) or (select max(id) from arsse_editions where article = arsse_articles.id) in (select edition from requested_articles))) as honour_read", - "((select starred from target_values) is not null and (select starred from target_values) <> (coalesce((select starred from arsse_marks where article = arsse_articles.id and subscription in (select sub from subscribed_feeds)),0))) as honour_star", - ]); - // common table expression with the values to set - $q->setCTE("target_values(read,starred,note)", "SELECT ?,?,?", ["bool","bool","str"], $values); - // push the current query onto the CTE stack and execute the query we're actually interested in - $q->pushCTE("target_articles"); - $q->setBody($query); - $out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); + if (isset($data['read']) && (isset($data['starred']) || isset($data['note'])) && ($context->edition() || $context->editions())) { + // if marking by edition both read and something else, do separate marks for starred and note than for read + // marking as read is ignored if the edition is not the latest, but the same is not true of the other two marks + $this->db->query("UPDATE arsse_marks set touched = 0 where touched <> 0"); + // set read marks + $q = $this->articleQuery($user, $context, ["id", "subscription"]); + $q->setWhere("arsse_marks.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']); + $q->pushCTE("target_articles(article,subscription)"); + $q->setBody("UPDATE arsse_marks set read = ?, touched = 1 where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", "bool", $data['read']); + $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); + // get the articles associated with the requested editions + if ($context->edition()) { + $context->article($this->articleValidateEdition($user, $context->edition)['article'])->edition(null); + } else { + $context->articles($this->editionArticle(...$context->editions))->editions(null); + } + // set starred and/or note marks (unless all requested editions actually do not exist) + if ($context->article || $context->articles) { + $q = $this->articleQuery($user, $context, ["id", "subscription"]); + $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred))", ["str", "bool"], [$data['note'], $data['starred']]); + $q->pushCTE("target_articles(article,subscription)"); + $data = array_filter($data, function($v) { + return isset($v); + }); + list($set, $setTypes, $setValues) = $this->generateSet($data, ['starred' => "bool", 'note' => "str"]); + $q->setBody("UPDATE arsse_marks set touched = 1, $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues); + $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); + } + // finally set the modification date for all touched marks and return the number of affected marks + $out = $this->db->query("UPDATE arsse_marks set modified = CURRENT_TIMESTAMP, touched = 0 where touched = 1")->changes(); + } else { + if (!isset($data['read']) && ($context->edition() || $context->editions())) { + // get the articles associated with the requested editions + if ($context->edition()) { + $context->article($this->articleValidateEdition($user, $context->edition)['article'])->edition(null); + } else { + $context->articles($this->editionArticle(...$context->editions))->editions(null); + } + if (!$context->article && !$context->articles) { + return 0; + } + } + $q = $this->articleQuery($user, $context, ["id", "subscription"]); + $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['read']]); + $q->pushCTE("target_articles(article,subscription)"); + $data = array_filter($data, function($v) { + return isset($v); + }); + list($set, $setTypes, $setValues) = $this->generateSet($data, ['read' => "bool", 'starred' => "bool", 'note' => "str"]); + $q->setBody("UPDATE arsse_marks set $set, modified = CURRENT_TIMESTAMP where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues); + $out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); } - // commit the transaction $tr->commit(); return $out; } @@ -1125,11 +1132,11 @@ class Database { return $this->db->prepare( "SELECT count(*) as total, - coalesce(sum(not read),0) as unread, + coalesce(sum(abs(read - 1)),0) as unread, coalesce(sum(read),0) as read FROM ( select read from arsse_marks where starred = 1 and subscription in (select id from arsse_subscriptions where owner = ?) - )", + ) as starred_data", "str" )->run($user)->getRow(); } @@ -1140,12 +1147,10 @@ class Database { } $id = $this->articleValidateId($user, $id)['article']; $out = $this->db->prepare("SELECT id,name from arsse_labels where owner = ? and exists(select id from arsse_label_members where article = ? and label = arsse_labels.id and assigned = 1)", "str", "int")->run($user, $id)->getAll(); - if (!$out) { - return $out; - } else { - // flatten the result to return just the label ID or name - return array_column($out, !$byName ? "id" : "name"); - } + // flatten the result to return just the label ID or name, sorted + $out = $out ? array_column($out, !$byName ? "id" : "name") : []; + sort($out); + return $out; } public function articleCategoriesGet(string $user, $id): array { @@ -1168,11 +1173,15 @@ class Database { "SELECT id, (select count(*) from arsse_subscriptions where feed = arsse_feeds.id) as subs from arsse_feeds where id = ?". + "), latest_editions(article,edition) as (". + "SELECT article,max(id) from arsse_editions group by article". "), excepted_articles(id,edition) as (". "SELECT - arsse_articles.id, (select max(id) from arsse_editions where article = arsse_articles.id) as edition + arsse_articles.id as id, + latest_editions.edition as edition from arsse_articles join target_feed on arsse_articles.feed = target_feed.id + join latest_editions on arsse_articles.id = latest_editions.article order by edition desc limit ?". ") ". "DELETE from arsse_articles where @@ -1240,14 +1249,14 @@ class Database { join arsse_feeds on arsse_feeds.id = arsse_articles.feed join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id WHERE - edition = ? and arsse_subscriptions.owner = ?", + arsse_editions.id = ? and arsse_subscriptions.owner = ?", "int", "str" )->run($id, $user)->getRow(); if (!$out) { throw new Db\ExceptionInput("subjectMissing", ["action" => $this->caller(), "field" => "edition", 'id' => $id]); } - return $out; + return array_map("intval", $out); } public function editionLatest(string $user, Context $context = null): int { @@ -1255,19 +1264,35 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $context = $context ?? new Context; - $q = new Query("SELECT max(arsse_editions.id) from arsse_editions left join arsse_articles on article = arsse_articles.id left join arsse_feeds on arsse_articles.feed = arsse_feeds.id"); + $q = new Query("SELECT max(arsse_editions.id) from arsse_editions left join arsse_articles on article = arsse_articles.id join arsse_subscriptions on arsse_articles.feed = arsse_subscriptions.feed and arsse_subscriptions.owner = ?", "str", $user); if ($context->subscription()) { // if a subscription is specified, make sure it exists - $id = $this->subscriptionValidateId($user, $context->subscription)['feed']; + $this->subscriptionValidateId($user, $context->subscription); // a simple WHERE clause is required here - $q->setWhere("arsse_feeds.id = ?", "int", $id); - } else { - $q->setCTE("user(user)", "SELECT ?", "str", $user); - $q->setCTE("feeds(feed)", "SELECT feed from arsse_subscriptions join user on user = owner", [], [], "join feeds on arsse_articles.feed = feeds.feed"); + $q->setWhere("arsse_subscriptions.id = ?", "int", $context->subscription); } return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } + public function editionArticle(int ...$edition): array { + $out = []; + $context = (new Context)->editions($edition); + // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result + if ($contexts = $this->contextChunk($context)) { + $articles = $editions = []; + foreach ($contexts as $context) { + $out = $this->editionArticle(...$context->editions); + $editions = array_merge($editions, array_map("intval", array_keys($out))); + $articles = array_merge($articles, array_map("intval", array_values($out))); + } + return array_combine($editions, $articles); + } else { + list($in, $inTypes) = $this->generateIn($context->editions, "int"); + $out = $this->db->prepare("SELECT id as edition, article from arsse_editions where id in($in)", $inTypes)->run($context->editions)->getAll(); + return $out ? array_combine(array_column($out, "edition"), array_column($out, "article")) : []; + } + } + public function labelAdd(string $user, array $data): int { // if the user isn't authorized to perform this action then throw an exception. if (!Arsse::$user->authorize($user, __FUNCTION__)) { @@ -1286,14 +1311,16 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } return $this->db->prepare( - "SELECT - id,name, - (select count(*) from arsse_label_members where label = id and assigned = 1) as articles, - (select count(*) from arsse_label_members - join arsse_marks on arsse_label_members.article = arsse_marks.article and arsse_label_members.subscription = arsse_marks.subscription - where label = id and assigned = 1 and read = 1 - ) as read - FROM arsse_labels where owner = ? and articles >= ? order by name + "SELECT * FROM ( + SELECT + id,name, + (select count(*) from arsse_label_members where label = id and assigned = 1) as articles, + (select count(*) from arsse_label_members + join arsse_marks on arsse_label_members.article = arsse_marks.article and arsse_label_members.subscription = arsse_marks.subscription + where label = id and assigned = 1 and read = 1 + ) as read + FROM arsse_labels where owner = ?) as label_data + where articles >= ? order by name ", "str", "int" @@ -1373,7 +1400,7 @@ class Database { $this->labelValidateId($user, $id, $byName, false); $field = !$byName ? "id" : "name"; $type = !$byName ? "int" : "str"; - $out = $this->db->prepare("SELECT article from arsse_label_members join arsse_labels on label = id where assigned = 1 and $field = ? and owner = ?", $type, "str")->run($id, $user)->getAll(); + $out = $this->db->prepare("SELECT article from arsse_label_members join arsse_labels on label = id where assigned = 1 and $field = ? and owner = ? order by article", $type, "str")->run($id, $user)->getAll(); if (!$out) { // if no results were returned, do a full validation on the label ID $this->labelValidateId($user, $id, $byName, true, true); @@ -1400,14 +1427,14 @@ class Database { $q->setWhere("exists(select article from arsse_label_members where label = ? and article = arsse_articles.id)", "int", $id); $q->pushCTE("target_articles"); $q->setBody( - "UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned = not ? and article in (select id from target_articles)", + "UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", ["bool","int","bool"], [!$remove, $id, !$remove] ); $out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); // next, if we're not removing, add any new entries that need to be added if (!$remove) { - $q = $this->articleQuery($user, $context); + $q = $this->articleQuery($user, $context, ["id", "feed"]); $q->setWhere("not exists(select article from arsse_label_members where label = ? and article = arsse_articles.id)", "int", $id); $q->pushCTE("target_articles"); $q->setBody( @@ -1415,10 +1442,10 @@ class Database { arsse_label_members(label,article,subscription) SELECT ?,id, - (select id from arsse_subscriptions join user on user = owner where arsse_subscriptions.feed = target_articles.feed) + (select id from arsse_subscriptions where owner = ? and arsse_subscriptions.feed = target_articles.feed) FROM target_articles", - "int", - $id + ["int", "str"], + [$id, $user] ); $out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); } diff --git a/lib/Db/AbstractDriver.php b/lib/Db/AbstractDriver.php index 9d2867b..6dbfb0a 100644 --- a/lib/Db/AbstractDriver.php +++ b/lib/Db/AbstractDriver.php @@ -13,18 +13,10 @@ abstract class AbstractDriver implements Driver { protected $transDepth = 0; protected $transStatus = []; + abstract protected function lock(): bool; + abstract protected function unlock(bool $rollback = false): bool; abstract protected function getError(): string; - /** @codeCoverageIgnore */ - public function schemaVersion(): int { - // FIXME: generic schemaVersion() will need to be covered for database engines other than SQLite - try { - return (int) $this->query("SELECT value from arsse_meta where key is schema_version")->getValue(); - } catch (Exception $e) { - return 0; - } - } - public function schemaUpdate(int $to, string $basePath = null): bool { $ver = $this->schemaVersion(); if (!Arsse::$conf->dbAutoUpdate) { @@ -78,50 +70,63 @@ abstract class AbstractDriver implements Driver { } public function savepointCreate(bool $lock = false): int { + // if no transaction is active and a lock was requested, lock the database using a backend-specific routine if ($lock && !$this->transDepth) { $this->lock(); $this->locked = true; } + // create a savepoint, incrementing the transaction depth $this->exec("SAVEPOINT arsse_".(++$this->transDepth)); + // set the state of the newly created savepoint to pending $this->transStatus[$this->transDepth] = self::TR_PEND; + // return the depth number return $this->transDepth; } public function savepointRelease(int $index = null): bool { + // assume the most recent savepoint if none was specified $index = $index ?? $this->transDepth; if (array_key_exists($index, $this->transStatus)) { switch ($this->transStatus[$index]) { case self::TR_PEND: + // release the requested savepoint and set its state to committed $this->exec("RELEASE SAVEPOINT arsse_".$index); $this->transStatus[$index] = self::TR_COMMIT; + // for any later pending savepoints, set their state to implicitly committed $a = $index; while (++$a && $a <= $this->transDepth) { if ($this->transStatus[$a] <= self::TR_PEND) { $this->transStatus[$a] = self::TR_PEND_COMMIT; } } + // return success $out = true; break; case self::TR_PEND_COMMIT: + // set the state to explicitly committed $this->transStatus[$index] = self::TR_COMMIT; $out = true; break; case self::TR_PEND_ROLLBACK: + // set the state to explicitly committed $this->transStatus[$index] = self::TR_COMMIT; $out = false; break; case self::TR_COMMIT: case self::TR_ROLLBACK: //@codeCoverageIgnore + // savepoint has already been released or rolled back; this is an error throw new Exception("savepointStale", ['action' => "commit", 'index' => $index]); default: throw new Exception("savepointStatusUnknown", $this->transStatus[$index]); // @codeCoverageIgnore } if ($index==$this->transDepth) { + // if we've released the topmost savepoint, clean up all prior savepoints which have already been explicitly committed (or rolled back), if any while ($this->transDepth > 0 && $this->transStatus[$this->transDepth] > self::TR_PEND) { array_pop($this->transStatus); $this->transDepth--; } } + // if no savepoints are pending and the database was locked, unlock it if (!$this->transDepth && $this->locked) { $this->unlock(); $this->locked = false; diff --git a/lib/Db/AbstractStatement.php b/lib/Db/AbstractStatement.php index 57185ee..acc9650 100644 --- a/lib/Db/AbstractStatement.php +++ b/lib/Db/AbstractStatement.php @@ -74,20 +74,26 @@ abstract class AbstractStatement implements Statement { } } - protected function bindValues(array $values, int $offset = 0): int { - $a = $offset; + protected function bindValues(array $values, int $offset = null): int { + $a = (int) $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++; + $this->bindValue($value, $this->types[$a], ++$a); } else { throw new Exception("paramTypeMissing", $a+1); } } + // once the last value is bound, check that all parameters have been supplied values and bind null for any missing ones + // SQLite will happily substitute null for a missing value, but other engines (viz. PostgreSQL) produce an error + if (is_null($offset)) { + while ($a < sizeof($this->types)) { + $this->bindValue(null, $this->types[$a], ++$a); + } + } return $a - $offset; } } diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index 56f5e8d..64eca65 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -39,4 +39,6 @@ interface Driver { public function prepareArray(string $query, array $paramTypes): Statement; // report whether the database character set is correct/acceptable public function charsetAcceptable(): bool; + // return an implementation-dependent form of a reference SQL function or operator + public function sqlToken(string $token): string; } diff --git a/lib/Db/PDODriver.php b/lib/Db/PDODriver.php index c6ec0d4..413a0cc 100644 --- a/lib/Db/PDODriver.php +++ b/lib/Db/PDODriver.php @@ -26,13 +26,7 @@ trait PDODriver { 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]); + return new PDOResult($this->db, $r); } public function prepareArray(string $query, array $paramTypes): Statement { diff --git a/lib/Db/PDOError.php b/lib/Db/PDOError.php index 03e1f89..7e8252d 100644 --- a/lib/Db/PDOError.php +++ b/lib/Db/PDOError.php @@ -7,15 +7,23 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Db; trait PDOError { - public function exceptionBuild(): array { - if ($this instanceof Statement) { + public function exceptionBuild(bool $statementError = null): array { + if ($statementError ?? ($this instanceof Statement)) { $err = $this->st->errorInfo(); } else { $err = $this->db->errorInfo(); } switch ($err[0]) { + case "22P02": + case "42804": + return [ExceptionInput::class, 'engineTypeViolation', $err[2]]; case "23000": + case "23502": + case "23505": return [ExceptionInput::class, "constraintViolation", $err[2]]; + case "55P03": + case "57014": + return [ExceptionTimeout::class, 'general', $err[2]]; case "HY000": // engine-specific errors switch ($this->db->getAttribute(\PDO::ATTR_DRIVER_NAME)) { diff --git a/lib/Db/PDOResult.php b/lib/Db/PDOResult.php index 8889511..d817d01 100644 --- a/lib/Db/PDOResult.php +++ b/lib/Db/PDOResult.php @@ -10,26 +10,28 @@ use JKingWeb\Arsse\Db\Exception; class PDOResult extends AbstractResult { protected $set; + protected $db; protected $cur = null; - protected $rows = 0; - protected $id = 0; // actual public methods public function changes(): int { - return $this->rows; + return $this->set->rowCount(); } public function lastId(): int { - return $this->id; + try { + return (int) $this->db->lastInsertId(); + } catch (\PDOException $e) { + return 0; + } } // constructor/destructor - public function __construct(\PDOStatement $result, array $changes = [0,0]) { + public function __construct(\PDO $db, \PDOStatement $result) { $this->set = $result; - $this->rows = (int) $changes[0]; - $this->id = (int) $changes[1]; + $this->db = $db; } public function __destruct() { @@ -38,6 +40,7 @@ class PDOResult extends AbstractResult { } catch (\PDOException $e) { // @codeCoverageIgnore } unset($this->set); + unset($this->db); } // PHP iterator methods diff --git a/lib/Db/PDOStatement.php b/lib/Db/PDOStatement.php index cfd9cef..3a09015 100644 --- a/lib/Db/PDOStatement.php +++ b/lib/Db/PDOStatement.php @@ -15,7 +15,7 @@ class PDOStatement extends AbstractStatement { "datetime" => \PDO::PARAM_STR, "binary" => \PDO::PARAM_LOB, "string" => \PDO::PARAM_STR, - "boolean" => \PDO::PARAM_BOOL, + "boolean" => \PDO::PARAM_INT, // FIXME: using \PDO::PARAM_BOOL leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 ]; protected $st; @@ -28,10 +28,10 @@ class PDOStatement extends AbstractStatement { } public function __destruct() { - unset($this->st); + unset($this->st, $this->db); } - public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result { + public function runArray(array $values = []): Result { $this->st->closeCursor(); $this->bindValues($values); try { @@ -40,13 +40,7 @@ class PDOStatement extends AbstractStatement { 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]); + return new PDOResult($this->db, $this->st); } protected function bindValue($value, string $type, int $position): bool { diff --git a/lib/Db/PostgreSQL/Dispatch.php b/lib/Db/PostgreSQL/Dispatch.php new file mode 100644 index 0000000..c6cb198 --- /dev/null +++ b/lib/Db/PostgreSQL/Dispatch.php @@ -0,0 +1,42 @@ +db, $query, $params); + $result = pg_get_result($this->db); + if (($code = pg_result_error_field($result, \PGSQL_DIAG_SQLSTATE)) && isset($code) && $code) { + return $this->buildException($code, pg_result_error($result)); + } else { + return $result; + } + } + + protected function buildException(string $code, string $msg): array { + switch ($code) { + case "22P02": + case "42804": + return [ExceptionInput::class, 'engineTypeViolation', $msg]; + case "23000": + case "23502": + case "23505": + return [ExceptionInput::class, "engineConstraintViolation", $msg]; + case "55P03": + case "57014": + return [ExceptionTimeout::class, 'general', $msg]; + default: + return [Exception::class, "engineErrorGeneral", $code.": ".$msg]; // @codeCoverageIgnore + } + } +} diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php new file mode 100644 index 0000000..89e7a7c --- /dev/null +++ b/lib/Db/PostgreSQL/Driver.php @@ -0,0 +1,223 @@ +dbPostgreSQLUser; + $pass = $pass ?? Arsse::$conf->dbPostgreSQLPass; + $db = $db ?? Arsse::$conf->dbPostgreSQLDb; + $host = $host ?? Arsse::$conf->dbPostgreSQLHost; + $port = $port ?? Arsse::$conf->dbPostgreSQLPort; + $schema = $schema ?? Arsse::$conf->dbPostgreSQLSchema; + $service = $service ?? Arsse::$conf->dbPostgreSQLService; + $this->makeConnection($user, $pass, $db, $host, $port, $service); + foreach (static::makeSetupQueries($schema) as $q) { + $this->exec($q); + } + } + + public static function makeConnectionString(bool $pdo, string $user, string $pass, string $db, string $host, int $port, string $service): string { + $base = [ + 'client_encoding' => "UTF8", + 'application_name' => "arsse", + 'connect_timeout' => (string) ceil(Arsse::$conf->dbTimeoutConnect ?? 0), + ]; + $out = []; + if ($service != "") { + $out['service'] = $service; + } else { + if ($host != "") { + $out['host'] = $host; + } + if ($port != 5432 && !($host != "" && $host[0] == "/")) { + $out['port'] = (string) $port; + } + if ($db != "") { + $out['dbname'] = $db; + } + if (!$pdo) { + $out['user'] = $user; + if ($pass != "") { + $out['password'] = $pass; + } + } + } + ksort($out); + ksort($base); + $out = array_merge($out, $base); + $out = array_map(function($v, $k) { + return "$k='".str_replace("'", "\\'", str_replace("\\", "\\\\", $v))."'"; + }, $out, array_keys($out)); + return implode(" ", $out); + } + + public static function makeSetupQueries(string $schema = ""): array { + $timeout = ceil(Arsse::$conf->dbTimeoutExec * 1000); + $out = [ + "SET TIME ZONE UTC", + "SET DateStyle = 'ISO, MDY'", + "SET statement_timeout = '$timeout'", + ]; + if (strlen($schema) > 0) { + $schema = '"'.str_replace('"', '""', $schema).'"'; + $out[] = "SET search_path = $schema, public"; + } + return $out; + } + + /** @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 schemaID(): string { + return "PostgreSQL"; + } + + public function charsetAcceptable(): bool { + return $this->query("SELECT pg_encoding_to_char(encoding) from pg_database where datname = current_database()")->getValue() == "UTF8"; + } + + public function schemaVersion(): int { + if ($this->query("SELECT count(*) from information_schema.tables where table_name = 'arsse_meta' and table_schema = current_schema()")->getValue()) { + return (int) $this->query("SELECT value from arsse_meta where key = 'schema_version'")->getValue(); + } else { + return 0; + } + } + + public function sqlToken(string $token): string { + switch (strtolower($token)) { + case "nocase": + return '"und-x-icu"'; + default: + return $token; + } + } + + public function savepointCreate(bool $lock = false): int { + if (!$this->transStart) { + $this->exec("BEGIN TRANSACTION"); + $this->transStart = parent::savepointCreate($lock); + return $this->transStart; + } else { + return parent::savepointCreate($lock); + } + } + + public function savepointRelease(int $index = null): bool { + $index = $index ?? $this->transDepth; + $out = parent::savepointRelease($index); + if ($index == $this->transStart) { + $this->exec("COMMIT"); + $this->transStart = 0; + } + return $out; + } + + public function savepointUndo(int $index = null): bool { + $index = $index ?? $this->transDepth; + $out = parent::savepointUndo($index); + if ($index == $this->transStart) { + $this->exec("ROLLBACK"); + $this->transStart = 0; + } + return $out; + } + + protected function lock(): bool { + if ($this->query("SELECT count(*) from information_schema.tables where table_schema = current_schema() and table_name = 'arsse_meta'")->getValue()) { + $this->exec("LOCK TABLE arsse_meta IN EXCLUSIVE MODE NOWAIT"); + } + return true; + } + + protected function unlock(bool $rollback = false): bool { + // do nothing; transaction is committed or rolled back later + return true; + } + + public function __destruct() { + if (isset($this->db)) { + pg_close($this->db); + unset($this->db); + } + } + + public static function driverName(): string { + return Arsse::$lang->msg("Driver.Db.PostgreSQL.Name"); + } + + public static function requirementsMet(): bool { + return \extension_loaded("pgsql"); + } + + protected function makeConnection(string $user, string $pass, string $db, string $host, int $port, string $service) { + $dsn = $this->makeconnectionString(false, $user, $pass, $db, $host, $port, $service); + set_error_handler(function(int $code, string $msg) { + $msg = substr($msg, 62); + throw new Exception("connectionFailure", ["PostgreSQL", $msg]); + }); + try { + $this->db = pg_connect($dsn, \PGSQL_CONNECT_FORCE_NEW); + } finally { + restore_error_handler(); + } + } + + protected function getError(): string { + // stub + return ""; + } + + public function exec(string $query): bool { + pg_send_query($this->db, $query); + while ($result = pg_get_result($this->db)) { + if (($code = pg_result_error_field($result, \PGSQL_DIAG_SQLSTATE)) && isset($code) && $code) { + list($excClass, $excMsg, $excData) = $this->buildException($code, pg_result_error($result)); + throw new $excClass($excMsg, $excData); + } + } + return true; + } + + public function query(string $query): \JKingWeb\Arsse\Db\Result { + $r = $this->dispatchQuery($query); + if (is_resource($r)) { + return new Result($this->db, $r); + } else { + list($excClass, $excMsg, $excData) = $r; + throw new $excClass($excMsg, $excData); + } + } + + public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { + return new Statement($this->db, $query, $paramTypes); + } +} diff --git a/lib/Db/PostgreSQL/PDODriver.php b/lib/Db/PostgreSQL/PDODriver.php new file mode 100644 index 0000000..c4eebd5 --- /dev/null +++ b/lib/Db/PostgreSQL/PDODriver.php @@ -0,0 +1,66 @@ +makeconnectionString(true, $user, $pass, $db, $host, $port, $service); + try { + $this->db = new \PDO("pgsql:$dsn", $user, $pass, [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_PERSISTENT => true, + ]); + } catch (\PDOException $e) { + if ($e->getCode() == 7) { + switch (substr($e->getMessage(), 9, 5)) { + case "08006": + throw new Exception("connectionFailure", ["PostgreSQL", substr($e->getMessage(), 28)]); + default: + throw $e; // @codeCoverageIgnore + } + } + throw $e; // @codeCoverageIgnore + } + } + + 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.PostgreSQLPDO.Name"); + } + + public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { + return new PDOStatement($this->db, $query, $paramTypes); + } +} diff --git a/lib/Db/PostgreSQL/PDOStatement.php b/lib/Db/PostgreSQL/PDOStatement.php new file mode 100644 index 0000000..534efbc --- /dev/null +++ b/lib/Db/PostgreSQL/PDOStatement.php @@ -0,0 +1,56 @@ +db = $db; + $this->qOriginal = $query; + $this->retypeArray($bindings); + } + + public function __destruct() { + unset($this->db, $this->st); + } + + public function retypeArray(array $bindings, bool $append = false): bool { + if ($append) { + return parent::retypeArray($bindings, $append); + } else { + $this->bindings = $bindings; + parent::retypeArray($bindings, $append); + $this->qMunged = self::mungeQuery($this->qOriginal, $this->types, false); + try { + // statement creation with PostgreSQL should never fail (it is not evaluated at creation time) + $s = $this->db->prepare($this->qMunged); + } catch (\PDOException $e) { // @codeCoverageIgnore + list($excClass, $excMsg, $excData) = $this->exceptionBuild(true); // @codeCoverageIgnore + throw new $excClass($excMsg, $excData); // @codeCoverageIgnore + } + $this->st = new \JKingWeb\Arsse\Db\PDOStatement($this->db, $s, $this->bindings); + } + return true; + } + + public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result { + return $this->st->runArray($values); + } + + /** @codeCoverageIgnore */ + protected function bindValue($value, string $type, int $position): bool { + // stub required by abstract parent, but never used + return true; + } +} diff --git a/lib/Db/PostgreSQL/Result.php b/lib/Db/PostgreSQL/Result.php new file mode 100644 index 0000000..3b6cf9c --- /dev/null +++ b/lib/Db/PostgreSQL/Result.php @@ -0,0 +1,48 @@ +r); + } + + public function lastId(): int { + if ($r = @pg_query($this->db, "SELECT lastval()")) { + return (int) pg_fetch_result($r, 0, 0); + } else { + return 0; + } + } + + // constructor/destructor + + public function __construct($db, $result) { + $this->db = $db; + $this->r = $result; + } + + public function __destruct() { + pg_free_result($this->r); + unset($this->r, $this->db); + } + + // PHP iterator methods + + public function valid() { + $this->cur = pg_fetch_row($this->r, null, \PGSQL_ASSOC); + return ($this->cur !== false); + } +} diff --git a/lib/Db/PostgreSQL/Statement.php b/lib/Db/PostgreSQL/Statement.php new file mode 100644 index 0000000..bccd0fb --- /dev/null +++ b/lib/Db/PostgreSQL/Statement.php @@ -0,0 +1,77 @@ + "bigint", + "float" => "decimal", + "datetime" => "timestamp(0) without time zone", + "binary" => "bytea", + "string" => "text", + "boolean" => "smallint", // FIXME: using boolean leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 + ]; + + protected $db; + protected $in = []; + protected $qOriginal; + protected $qMunged; + protected $bindings; + + public function __construct($db, string $query, array $bindings = []) { + $this->db = $db; + $this->qOriginal = $query; + $this->retypeArray($bindings); + } + + public function retypeArray(array $bindings, bool $append = false): bool { + if ($append) { + return parent::retypeArray($bindings, $append); + } else { + $this->bindings = $bindings; + parent::retypeArray($bindings, $append); + $this->qMunged = self::mungeQuery($this->qOriginal, $this->types, true); + } + return true; + } + + public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result { + $this->in = []; + $this->bindValues($values); + $r = $this->dispatchQuery($this->qMunged, $this->in); + if (is_resource($r)) { + return new Result($this->db, $r); + } else { + list($excClass, $excMsg, $excData) = $r; + throw new $excClass($excMsg, $excData); + } + } + + protected function bindValue($value, string $type, int $position): bool { + $this->in[] = $value; + return true; + } + + protected static function mungeQuery(string $q, array $types, bool $mungeParamMarkers = true): string { + $q = explode("?", $q); + $out = ""; + for ($b = 1; $b < sizeof($q); $b++) { + $a = $b - 1; + $mark = $mungeParamMarkers ? "\$$b" : "?"; + $type = isset($types[$a]) ? "::".self::BINDINGS[$types[$a]] : ""; + $out .= $q[$a].$mark.$type; + } + $out .= array_pop($q); + return $out; + } +} diff --git a/lib/Db/ResultAggregate.php b/lib/Db/ResultAggregate.php index 4f53b9d..cc1a052 100644 --- a/lib/Db/ResultAggregate.php +++ b/lib/Db/ResultAggregate.php @@ -16,7 +16,7 @@ class ResultAggregate extends AbstractResult { // actual public methods public function changes(): int { - return array_reduce($this->data, function ($sum, $value) { + return array_reduce($this->data, function($sum, $value) { return $sum + $value->changes(); }, 0); } diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index c528b63..cee1b1c 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -31,10 +31,6 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { $timeout = Arsse::$conf->dbSQLite3Timeout * 1000; try { $this->makeConnection($dbFile, $dbKey); - // 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 - $this->exec("PRAGMA foreign_keys = yes"); } catch (\Throwable $e) { // if opening the database doesn't work, check various pre-conditions to find out what the problem might be $files = [ @@ -56,6 +52,15 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { // otherwise the database is probably corrupt throw new Exception("fileCorrupt", $dbFile); } + // set the timeout + $timeout = (int) ceil((Arsse::$conf->dbSQLite3Timeout ?? 0) * 1000); + $this->setTimeout($timeout); + // set other initial options + $this->exec("PRAGMA foreign_keys = yes"); + // use a case-insensitive Unicode collation sequence + $this->collator = new \Collator("@kf=false"); + $m = ($this->db instanceof \PDO) ? "sqliteCreateCollation" : "createCollation"; + $this->db->$m("nocase", [$this->collator, "compare"]); } public static function requirementsMet(): bool { @@ -68,6 +73,10 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { $this->db->enableExceptions(true); } + protected function setTimeout(int $msec) { + $this->exec("PRAGMA busy_timeout = $msec"); + } + public function __destruct() { try { $this->db->close(); @@ -100,20 +109,27 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { return (int) $this->query("PRAGMA user_version")->getValue(); } + public function sqlToken(string $token): string { + switch (strtolower($token)) { + case "greatest": + return "max"; + default: + return $token; + } + } + public function schemaUpdate(int $to, string $basePath = null): bool { // turn off foreign keys $this->exec("PRAGMA foreign_keys = no"); + $this->exec("PRAGMA legacy_alter_table = yes"); // run the generic updater try { parent::schemaUpdate($to, $basePath); - } catch (\Throwable $e) { + } finally { // turn foreign keys back on $this->exec("PRAGMA foreign_keys = yes"); - // pass the exception up - throw $e; + $this->exec("PRAGMA legacy_alter_table = no"); } - // turn foreign keys back on - $this->exec("PRAGMA foreign_keys = yes"); return true; } @@ -158,7 +174,13 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { } protected function lock(): bool { - $this->exec("BEGIN EXCLUSIVE TRANSACTION"); + $timeout = (int) $this->query("PRAGMA busy_timeout")->getValue(); + $this->setTimeout(0); + try { + $this->exec("BEGIN EXCLUSIVE TRANSACTION"); + } finally { + $this->setTimeout($timeout); + } return true; } diff --git a/lib/Db/SQLite3/PDODriver.php b/lib/Db/SQLite3/PDODriver.php index 42e1cf8..cbad93c 100644 --- a/lib/Db/SQLite3/PDODriver.php +++ b/lib/Db/SQLite3/PDODriver.php @@ -21,7 +21,9 @@ class PDODriver extends Driver { } protected function makeConnection(string $file, string $key) { - $this->db = new \PDO("sqlite:".$file, "", "", [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + $this->db = new \PDO("sqlite:".$file, "", "", [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + ]); } public function __destruct() { diff --git a/lib/Feed.php b/lib/Feed.php index 836ecc8..9ede4e4 100644 --- a/lib/Feed.php +++ b/lib/Feed.php @@ -33,14 +33,14 @@ class Feed { } else { $links = $f->reader->find($f->getUrl(), $f->getContent()); if (!$links) { - // work around a PicoFeed memory leak FIXME: remove this hack (or not) once PicoFeed stops leaking memory + // work around a PicoFeed memory leak libxml_use_internal_errors(false); throw new Feed\Exception($url, new \PicoFeed\Reader\SubscriptionNotFoundException('Unable to find a subscription')); } else { $out = $links[0]; } } - // work around a PicoFeed memory leak FIXME: remove this hack (or not) once PicoFeed stops leaking memory + // work around a PicoFeed memory leak libxml_use_internal_errors(false); return $out; } @@ -115,10 +115,10 @@ class Feed { // Some feeds might use a different domain (eg: feedburner), so the site url is // used instead of the feed's url. $this->favicon = (new Favicon)->find($feed->siteUrl); - // work around a PicoFeed memory leak FIXME: remove this hack (or not) once PicoFeed stops leaking memory + // work around a PicoFeed memory leak libxml_use_internal_errors(false); } catch (PicoFeedException $e) { - // work around a PicoFeed memory leak FIXME: remove this hack (or not) once PicoFeed stops leaking memory + // work around a PicoFeed memory leak libxml_use_internal_errors(false); throw new Feed\Exception($this->resource->getUrl(), $e); } diff --git a/lib/Lang.php b/lib/Lang.php index 6114ba7..a6e0735 100644 --- a/lib/Lang.php +++ b/lib/Lang.php @@ -140,7 +140,7 @@ class Lang { protected function listFiles(): array { $out = $this->globFiles($this->path."*.php"); // trim the returned file paths to return just the language tag - $out = array_map(function ($file) { + $out = array_map(function($file) { $file = str_replace(DIRECTORY_SEPARATOR, "/", $file); // we replace the directory separator because we don't use native paths in testing $file = substr($file, strrpos($file, "/")+1); return strtolower(substr($file, 0, strrpos($file, "."))); diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index 5fd61ff..93e4ac4 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -39,8 +39,13 @@ class Context { protected function act(string $prop, int $set, $value) { if ($set) { - $this->props[$prop] = true; - $this->$prop = $value; + if (is_null($value)) { + unset($this->props[$prop]); + $this->$prop = (new \ReflectionClass($this))->getDefaultProperties()[$prop]; + } else { + $this->props[$prop] = true; + $this->$prop = $value; + } return $this; } else { return isset($this->props[$prop]); @@ -136,14 +141,14 @@ class Context { } public function editions(array $spec = null) { - if ($spec) { + if (isset($spec)) { $spec = $this->cleanArray($spec); } return $this->act(__FUNCTION__, func_num_args(), $spec); } public function articles(array $spec = null) { - if ($spec) { + if (isset($spec)) { $spec = $this->cleanArray($spec); } return $this->act(__FUNCTION__, func_num_args(), $spec); diff --git a/lib/Misc/Query.php b/lib/Misc/Query.php index 9afc23d..d7a2c7f 100644 --- a/lib/Misc/Query.php +++ b/lib/Misc/Query.php @@ -20,6 +20,7 @@ class Query { protected $qWhere = []; // WHERE clause components protected $tWhere = []; // WHERE clause type bindings protected $vWhere = []; // WHERE clause binding values + protected $group = []; // GROUP BY clause components protected $order = []; // ORDER BY clause components protected $limit = 0; protected $offset = 0; @@ -68,6 +69,13 @@ class Query { return true; } + public function setGroup(string ...$column): bool { + foreach ($column as $col) { + $this->group[] = $col; + } + return true; + } + public function setOrder(string $order, bool $prepend = false): bool { if ($prepend) { array_unshift($this->order, $order); @@ -97,6 +105,7 @@ class Query { $this->tJoin = []; $this->vJoin = []; $this->order = []; + $this->group = []; $this->setLimit(0, 0); if (strlen($join)) { $this->jCTE[] = $join; @@ -167,6 +176,10 @@ class Query { if (sizeof($this->qWhere)) { $out .= " WHERE ".implode(" AND ", $this->qWhere); } + // add any GROUP BY terms + if (sizeof($this->group)) { + $out .= " GROUP BY ".implode(", ", $this->group); + } // add any ORDER BY terms if (sizeof($this->order)) { $out .= " ORDER BY ".implode(", ", $this->order); diff --git a/lib/REST.php b/lib/REST.php index ac527f1..70174c1 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -97,7 +97,7 @@ class REST { public function apiMatch(string $url): array { $map = $this->apis; // sort the API list so the longest URL prefixes come first - uasort($map, function ($a, $b) { + uasort($map, function($a, $b) { return (strlen($a['match']) <=> strlen($b['match'])) * -1; }); // normalize the target URL @@ -270,7 +270,7 @@ class REST { } else { // if the host is a domain name or IP address, split it along dots and just perform URL decoding $host = explode(".", $host); - $host = array_map(function ($segment) { + $host = array_map(function($segment) { return str_replace(".", "%2E", rawurlencode(strtolower(rawurldecode($segment)))); }, $host); $host = implode(".", $host); diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 12e6cf7..f16d453 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -563,7 +563,23 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { } // perform the fetch try { - $items = Arsse::$db->articleList(Arsse::$user->id, $c, Database::LIST_TYPICAL); + $items = Arsse::$db->articleList(Arsse::$user->id, $c, [ + "edition", + "guid", + "id", + "url", + "title", + "author", + "edited_date", + "content", + "media_type", + "media_url", + "subscription", + "unread", + "starred", + "modified_date", + "fingerprint", + ]); } catch (ExceptionInput $e) { // ID of subscription or folder is not valid return new EmptyResponse(422); diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 57bb5a8..014bbd6 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -330,7 +330,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'id' => "FEED:".self::FEED_ALL, 'bare_id' => self::FEED_ALL, 'icon' => "images/folder.png", - 'unread' => array_reduce($subs, function ($sum, $value) { + 'unread' => array_reduce($subs, function($sum, $value) { return $sum + $value['unread']; }, 0), // the sum of all feeds' unread is the total unread ], $tSpecial), @@ -1113,8 +1113,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $out += Arsse::$db->articleMark(Arsse::$user->id, ['starred' => (bool) $data['mode']], (new Context)->articles($articles)); break; case 2: //toggle - $on = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->starred(true), Database::LIST_MINIMAL)->getAll(), "id"); - $off = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->starred(false), Database::LIST_MINIMAL)->getAll(), "id"); + $on = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->starred(true), ["id"])->getAll(), "id"); + $off = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->starred(false), ["id"])->getAll(), "id"); if ($off) { $out += Arsse::$db->articleMark(Arsse::$user->id, ['starred' => true], (new Context)->articles($off)); } @@ -1145,8 +1145,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $out += Arsse::$db->articleMark(Arsse::$user->id, ['read' => !$data['mode']], (new Context)->articles($articles)); break; case 2: //toggle - $on = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->unread(true), Database::LIST_MINIMAL)->getAll(), "id"); - $off = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->unread(false), Database::LIST_MINIMAL)->getAll(), "id"); + $on = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->unread(true), ["id"])->getAll(), "id"); + $off = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->unread(false), ["id"])->getAll(), "id"); if ($off) { $out += Arsse::$db->articleMark(Arsse::$user->id, ['read' => false], (new Context)->articles($off)); } @@ -1183,7 +1183,22 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } // retrieve the requested articles $out = []; - foreach (Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)) as $article) { + $columns = [ + "id", + "guid", + "title", + "url", + "unread", + "starred", + "edited_date", + "subscription", + "subscription_title", + "note", + "content", + "media_url", + "media_type", + ]; + foreach (Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles), $columns) as $article) { $out[] = [ 'id' => (string) $article['id'], // string cast to be consistent with TTRSS 'guid' => $article['guid'] ? "SHA256:".$article['guid'] : null, @@ -1246,7 +1261,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // fetch the list of IDs $out = []; try { - foreach ($this->fetchArticles($data, Database::LIST_MINIMAL) as $row) { + foreach ($this->fetchArticles($data, ["id"]) as $row) { $out[] = ['id' => (int) $row['id']]; } } catch (ExceptionInput $e) { @@ -1267,7 +1282,23 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // retrieve the requested articles $out = []; try { - foreach ($this->fetchArticles($data, Database::LIST_FULL) as $article) { + $columns = [ + "id", + "guid", + "title", + "url", + "unread", + "starred", + "edited_date", + "published_date", + "subscription", + "subscription_title", + "note", + ($data['show_content'] || $data['show_excerpt']) ? "content" : "", + ($data['include_attachments']) ? "media_url": "", + ($data['include_attachments']) ? "media_type": "", + ]; + foreach ($this->fetchArticles($data, $columns) as $article) { $row = [ 'id' => (int) $article['id'], 'guid' => $article['guid'] ? "SHA256:".$article['guid'] : "", @@ -1325,7 +1356,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // when paginating the header returns the latest ("first") item ID in the full list; we get this ID here $data['skip'] = 0; $data['limit'] = 1; - $firstID = ($this->fetchArticles($data, Database::LIST_MINIMAL)->getRow() ?? ['id' => 0])['id']; + $firstID = ($this->fetchArticles($data, ["id"])->getRow() ?? ['id' => 0])['id']; } elseif ($data['order_by']=="date_reverse") { // the "date_reverse" sort order doesn't get a first ID because it's meaningless for ascending-order pagination (pages doesn't go stale) $firstID = 0; @@ -1346,7 +1377,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return $out; } - protected function fetchArticles(array $data, int $fields): \JKingWeb\Arsse\Db\Result { + protected function fetchArticles(array $data, array $fields): \JKingWeb\Arsse\Db\Result { // normalize input if (is_null($data['feed_id'])) { throw new Exception("INCORRECT_USAGE"); diff --git a/lib/User.php b/lib/User.php index 6ccdbcc..e5d8e17 100644 --- a/lib/User.php +++ b/lib/User.php @@ -9,7 +9,6 @@ namespace JKingWeb\Arsse; use PasswordGenerator\Generator as PassGen; class User { - public $id = null; /** diff --git a/locale/en.php b/locale/en.php index 55a0bd3..dc5381f 100644 --- a/locale/en.php +++ b/locale/en.php @@ -20,6 +20,8 @@ return [ 'Driver.Db.SQLite3.Name' => 'SQLite 3', 'Driver.Db.SQLite3PDO.Name' => 'SQLite 3 (PDO)', + 'Driver.Db.PostgreSQL.Name' => 'PostgreSQL', + 'Driver.Db.PostgreSQLPDO.Name' => 'PostgreSQL (PDO)', 'Driver.Service.Curl.Name' => 'HTTP (curl)', 'Driver.Service.Internal.Name' => 'Internal', 'Driver.User.Internal.Name' => 'Internal', @@ -120,6 +122,7 @@ return [ 'Exception.JKingWeb/Arsse/Db/Exception.fileUnusable' => 'Insufficient permissions to open database file "{0}" for reading or writing', 'Exception.JKingWeb/Arsse/Db/Exception.fileUncreatable' => 'Insufficient permissions to create new database file "{0}"', 'Exception.JKingWeb/Arsse/Db/Exception.fileCorrupt' => 'Database file "{0}" is corrupt or not a valid database', + 'Exception.JKingWeb/Arsse/Db/Exception.connectionFailure' => 'Could not connect to {0} database: {1}', 'Exception.JKingWeb/Arsse/Db/Exception.paramTypeInvalid' => 'Prepared statement parameter type "{0}" is invalid', 'Exception.JKingWeb/Arsse/Db/Exception.paramTypeUnknown' => 'Prepared statement parameter type "{0}" is valid, but not implemented', 'Exception.JKingWeb/Arsse/Db/Exception.paramTypeMissing' => 'Prepared statement parameter type for parameter #{0} was not specified', @@ -156,7 +159,7 @@ return [ 'Exception.JKingWeb/Arsse/Db/ExceptionInput.subjectMissing' => 'Referenced ID ({id}) in field "{field}" does not exist', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.idMissing' => 'Referenced ID ({id}) in field "{field}" does not exist', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.circularDependence' => 'Referenced ID ({id}) in field "{field}" creates a circular dependence', - 'Exception.JKingWeb/Arsse/Db/ExceptionInput.constraintViolation' => 'Specified value in field "{0}" already exists', + 'Exception.JKingWeb/Arsse/Db/ExceptionInput.constraintViolation' => 'Specified value in field "{field}" already exists', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.engineConstraintViolation' => '{0}', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.engineTypeViolation' => '{0}', 'Exception.JKingWeb/Arsse/Db/ExceptionTimeout.general' => '{0}', diff --git a/sql/PostgreSQL/0.sql b/sql/PostgreSQL/0.sql new file mode 100644 index 0000000..3d940f5 --- /dev/null +++ b/sql/PostgreSQL/0.sql @@ -0,0 +1,113 @@ +-- SPDX-License-Identifier: MIT +-- Copyright 2017 J. King, Dustin Wilson et al. +-- See LICENSE and AUTHORS files for details + +-- Please consult the SQLite 3 schemata for commented version + +create table arsse_meta( + key text primary key, + value text +); + +create table arsse_users( + id text primary key, + password text, + name text, + avatar_type text, + avatar_data bytea, + admin smallint default 0, + rights bigint not null default 0 +); + +create table arsse_users_meta( + owner text not null references arsse_users(id) on delete cascade on update cascade, + key text not null, + value text, + primary key(owner,key) +); + +create table arsse_folders( + id bigserial primary key, + owner text not null references arsse_users(id) on delete cascade on update cascade, + parent bigint references arsse_folders(id) on delete cascade, + name text not null, + modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP, -- + unique(owner,name,parent) +); + +create table arsse_feeds( + id bigserial primary key, + url text not null, + title text, + favicon text, + source text, + updated timestamp(0) without time zone, + modified timestamp(0) without time zone, + next_fetch timestamp(0) without time zone, + orphaned timestamp(0) without time zone, + etag text not null default '', + err_count bigint not null default 0, + err_msg text, + username text not null default '', + password text not null default '', + size bigint not null default 0, + scrape smallint not null default 0, + unique(url,username,password) +); + +create table arsse_subscriptions( + id bigserial primary key, + owner text not null references arsse_users(id) on delete cascade on update cascade, + feed bigint not null references arsse_feeds(id) on delete cascade, + added timestamp(0) without time zone not null default CURRENT_TIMESTAMP, + modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP, + title text, + order_type smallint not null default 0, + pinned smallint not null default 0, + folder bigint references arsse_folders(id) on delete cascade, + unique(owner,feed) +); + +create table arsse_articles( + id bigserial primary key, + feed bigint not null references arsse_feeds(id) on delete cascade, + url text, + title text, + author text, + published timestamp(0) without time zone, + edited timestamp(0) without time zone, + modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP, + content text, + guid text, + url_title_hash text not null, + url_content_hash text not null, + title_content_hash text not null +); + +create table arsse_enclosures( + article bigint not null references arsse_articles(id) on delete cascade, + url text, + type text +); + +create table arsse_marks( + article bigint not null references arsse_articles(id) on delete cascade, + subscription bigint not null references arsse_subscriptions(id) on delete cascade on update cascade, + read smallint not null default 0, + starred smallint not null default 0, + modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP, + primary key(article,subscription) +); + +create table arsse_editions( + id bigserial primary key, + article bigint not null references arsse_articles(id) on delete cascade, + modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP +); + +create table arsse_categories( + article bigint not null references arsse_articles(id) on delete cascade, + name text +); + +insert into arsse_meta(key,value) values('schema_version','1'); diff --git a/sql/PostgreSQL/1.sql b/sql/PostgreSQL/1.sql new file mode 100644 index 0000000..1549fd5 --- /dev/null +++ b/sql/PostgreSQL/1.sql @@ -0,0 +1,33 @@ +-- SPDX-License-Identifier: MIT +-- Copyright 2017 J. King, Dustin Wilson et al. +-- See LICENSE and AUTHORS files for details + +-- Please consult the SQLite 3 schemata for commented version + +create table arsse_sessions ( + id text primary key, + created timestamp(0) without time zone not null default CURRENT_TIMESTAMP, + expires timestamp(0) without time zone not null, + "user" text not null references arsse_users(id) on delete cascade on update cascade +); + +create table arsse_labels ( + id bigserial primary key, + owner text not null references arsse_users(id) on delete cascade on update cascade, + name text not null, + modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP, + unique(owner,name) +); + +create table arsse_label_members ( + label bigint not null references arsse_labels(id) on delete cascade, + article bigint not null references arsse_articles(id) on delete cascade, + subscription bigint not null references arsse_subscriptions(id) on delete cascade, + assigned smallint not null default 1, + modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP, + primary key(label,article) +); + +alter table arsse_marks add column note text not null default ''; + +update arsse_meta set value = '2' where key = 'schema_version'; diff --git a/sql/PostgreSQL/2.sql b/sql/PostgreSQL/2.sql new file mode 100644 index 0000000..847edb7 --- /dev/null +++ b/sql/PostgreSQL/2.sql @@ -0,0 +1,16 @@ +-- SPDX-License-Identifier: MIT +-- Copyright 2017 J. King, Dustin Wilson et al. +-- See LICENSE and AUTHORS files for details + +-- Please consult the SQLite 3 schemata for commented version + +alter table arsse_users alter column id type text collate "und-x-icu"; +alter table arsse_folders alter column name type text collate "und-x-icu"; +alter table arsse_feeds alter column title type text collate "und-x-icu"; +alter table arsse_subscriptions alter column title type text collate "und-x-icu"; +alter table arsse_articles alter column title type text collate "und-x-icu"; +alter table arsse_articles alter column author type text collate "und-x-icu"; +alter table arsse_categories alter column name type text collate "und-x-icu"; +alter table arsse_labels alter column name type text collate "und-x-icu"; + +update arsse_meta set value = '3' where key = 'schema_version'; diff --git a/sql/PostgreSQL/3.sql b/sql/PostgreSQL/3.sql new file mode 100644 index 0000000..2290ae5 --- /dev/null +++ b/sql/PostgreSQL/3.sql @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MIT +-- Copyright 2017 J. King, Dustin Wilson et al. +-- See LICENSE and AUTHORS files for details + +-- Please consult the SQLite 3 schemata for commented version + +alter table arsse_marks alter column modified drop default; +alter table arsse_marks alter column modified drop not null; +alter table arsse_marks add column touched smallint not null default 0; + +update arsse_meta set value = '4' where key = 'schema_version'; diff --git a/sql/SQLite3/0.sql b/sql/SQLite3/0.sql index add9b56..7a9dea6 100644 --- a/sql/SQLite3/0.sql +++ b/sql/SQLite3/0.sql @@ -5,14 +5,14 @@ -- Make the database WAL-journalled; this is persitent PRAGMA journal_mode = wal; --- metadata create table arsse_meta( +-- application metadata key text primary key not null, -- metadata key value text -- metadata value, serialized as a string ); --- users create table arsse_users( +-- users id text primary key not null, -- user id password text, -- password, salted and hashed; if using external authentication this would be blank name text, -- display name @@ -22,35 +22,38 @@ create table arsse_users( rights integer not null default 0 -- temporary admin-rights marker FIXME: remove reliance on this ); --- extra user metadata create table arsse_users_meta( +-- extra user metadata (not currently used and will be removed) owner text not null references arsse_users(id) on delete cascade on update cascade, key text not null, value text, primary key(owner,key) ); --- NextCloud News folders create table arsse_folders( +-- folders, used by NextCloud News and Tiny Tiny RSS +-- feed subscriptions may belong to at most one folder; +-- in Tiny Tiny RSS folders may nest id integer primary key, -- sequence number owner text not null references arsse_users(id) on delete cascade on update cascade, -- owner of folder parent integer references arsse_folders(id) on delete cascade, -- parent folder id name text not null, -- folder name - modified text not null default CURRENT_TIMESTAMP, -- + modified text not null default CURRENT_TIMESTAMP, -- time at which the folder itself (not its contents) was changed; not currently used unique(owner,name,parent) -- cannot have multiple folders with the same name under the same parent for the same owner ); --- newsfeeds, deduplicated create table arsse_feeds( +-- newsfeeds, deduplicated +-- users have subscriptions to these feeds in another table id integer primary key, -- sequence number url text not null, -- URL of feed - title text, -- default title of feed + title text, -- default title of feed (users can set the title of their subscription to the feed) favicon text, -- URL of favicon source text, -- URL of site to which the feed belongs updated text, -- time at which the feed was last fetched modified text, -- time at which the feed last actually changed next_fetch text, -- time at which the feed should next be fetched - orphaned text, -- time at which the feed last had no subscriptions + orphaned text, -- time at which the feed last had no subscriptions etag text not null default '', -- HTTP ETag hash used for cache validation, changes each time the content changes err_count integer not null default 0, -- count of successive times update resulted in error since last successful update err_msg text, -- last error message @@ -61,13 +64,13 @@ create table arsse_feeds( unique(url,username,password) -- a URL with particular credentials should only appear once ); --- users' subscriptions to newsfeeds, with settings create table arsse_subscriptions( +-- users' subscriptions to newsfeeds, with settings id integer primary key, -- sequence number owner text not null references arsse_users(id) on delete cascade on update cascade, -- owner of subscription feed integer not null references arsse_feeds(id) on delete cascade, -- feed for the subscription added text not null default CURRENT_TIMESTAMP, -- time at which feed was added - modified text not null default CURRENT_TIMESTAMP, -- date at which subscription properties were last modified + modified text not null default CURRENT_TIMESTAMP, -- time at which subscription properties were last modified title text, -- user-supplied title order_type int not null default 0, -- NextCloud sort order pinned boolean not null default 0, -- whether feed is pinned (always sorts at top) @@ -75,16 +78,16 @@ create table arsse_subscriptions( unique(owner,feed) -- a given feed should only appear once for a given owner ); --- entries in newsfeeds create table arsse_articles( +-- entries in newsfeeds id integer primary key, -- sequence number feed integer not null references arsse_feeds(id) on delete cascade, -- feed for the subscription url text, -- URL of article title text, -- article title author text, -- author's name published text, -- time of original publication - edited text, -- time of last edit - modified text not null default CURRENT_TIMESTAMP, -- date when article properties were last modified + edited text, -- time of last edit by author + modified text not null default CURRENT_TIMESTAMP, -- time when article was last modified in database content text, -- content, as (X)HTML guid text, -- GUID url_title_hash text not null, -- hash of URL + title; used when checking for updates and for identification if there is no guid. @@ -92,34 +95,37 @@ create table arsse_articles( title_content_hash text not null -- hash of title + content, enclosure URL, & content type; used when checking for updates and for identification if there is no guid. ); --- enclosures associated with articles create table arsse_enclosures( - article integer not null references arsse_articles(id) on delete cascade, - url text, - type text +-- enclosures (attachments) associated with articles + article integer not null references arsse_articles(id) on delete cascade, -- article to which the enclosure belongs + url text, -- URL of the enclosure + type text -- content-type (MIME type) of the enclosure ); --- users' actions on newsfeed entries create table arsse_marks( - article integer not null references arsse_articles(id) on delete cascade, - subscription integer not null references arsse_subscriptions(id) on delete cascade on update cascade, - read boolean not null default 0, - starred boolean not null default 0, - modified text not null default CURRENT_TIMESTAMP, - primary key(article,subscription) + article integer not null references arsse_articles(id) on delete cascade, -- article associated with the marks + subscription integer not null references arsse_subscriptions(id) on delete cascade on update cascade, -- subscription associated with the marks; the subscription in turn belongs to a user + read boolean not null default 0, -- whether the article has been read + starred boolean not null default 0, -- whether the article is starred + modified text not null default CURRENT_TIMESTAMP, -- time at which an article was last modified by a given user + primary key(article,subscription) -- no more than one mark-set per article per user ); --- IDs for specific editions of articles (required for at least NextCloud News) create table arsse_editions( - id integer primary key, - article integer not null references arsse_articles(id) on delete cascade, - modified datetime not null default CURRENT_TIMESTAMP +-- IDs for specific editions of articles (required for at least NextCloud News) +-- every time an article is updated by its author, a new unique edition number is assigned +-- with NextCloud News this prevents users from marking as read an article which has been +-- updated since the client state was last refreshed + id integer primary key, -- sequence number + article integer not null references arsse_articles(id) on delete cascade, -- the article of which this is an edition + modified datetime not null default CURRENT_TIMESTAMP -- tiem at which the edition was modified (practically, when it was created) ); --- author categories associated with newsfeed entries create table arsse_categories( - article integer not null references arsse_articles(id) on delete cascade, - name text +-- author categories associated with newsfeed entries +-- these are not user-modifiable + article integer not null references arsse_articles(id) on delete cascade, -- article associated with the category + name text -- freeform name of the category ); -- set version marker diff --git a/sql/SQLite3/1.sql b/sql/SQLite3/1.sql index 8f273e6..b96bd79 100644 --- a/sql/SQLite3/1.sql +++ b/sql/SQLite3/1.sql @@ -2,16 +2,16 @@ -- Copyright 2017 J. King, Dustin Wilson et al. -- See LICENSE and AUTHORS files for details --- Sessions for Tiny Tiny RSS (and possibly others) create table arsse_sessions ( +-- sessions for Tiny Tiny RSS (and possibly others) id text primary key, -- UUID of session created text not null default CURRENT_TIMESTAMP, -- Session start timestamp expires text not null, -- Time at which session is no longer valid user text not null references arsse_users(id) on delete cascade on update cascade -- user associated with the session ) without rowid; --- User-defined article labels for Tiny Tiny RSS create table arsse_labels ( +-- user-defined article labels for Tiny Tiny RSS id integer primary key, -- numeric ID owner text not null references arsse_users(id) on delete cascade on update cascade, -- owning user name text not null, -- label text @@ -19,30 +19,33 @@ create table arsse_labels ( unique(owner,name) ); --- Labels assignments for articles create table arsse_label_members ( - label integer not null references arsse_labels(id) on delete cascade, - article integer not null references arsse_articles(id) on delete cascade, +-- uabels assignments for articles + label integer not null references arsse_labels(id) on delete cascade, -- label ID associated to an article; label IDs belong to a user + article integer not null references arsse_articles(id) on delete cascade, -- article associated to a label subscription integer not null references arsse_subscriptions(id) on delete cascade, -- Subscription is included so that records are deleted when a subscription is removed - assigned boolean not null default 1, - modified text not null default CURRENT_TIMESTAMP, - primary key(label,article) + assigned boolean not null default 1, -- whether the association is current, to support soft deletion + modified text not null default CURRENT_TIMESTAMP, -- time at which the association was last made or unmade + primary key(label,article) -- only one association of a given label to a given article ) without rowid; -- alter marks table to add Tiny Tiny RSS' notes +-- SQLite has limited ALTER TABLE support, so the table must be re-created +-- and its data re-entered; other database systems have a much simpler prodecure alter table arsse_marks rename to arsse_marks_old; create table arsse_marks( - article integer not null references arsse_articles(id) on delete cascade, - subscription integer not null references arsse_subscriptions(id) on delete cascade on update cascade, - read boolean not null default 0, - starred boolean not null default 0, - modified text not null default CURRENT_TIMESTAMP, - note text not null default '', - primary key(article,subscription) +-- users' actions on newsfeed entries + article integer not null references arsse_articles(id) on delete cascade, -- article associated with the marks + subscription integer not null references arsse_subscriptions(id) on delete cascade on update cascade, -- subscription associated with the marks; the subscription in turn belongs to a user + read boolean not null default 0, -- whether the article has been read + starred boolean not null default 0, -- whether the article is starred + modified text not null default CURRENT_TIMESTAMP, -- time at which an article was last modified by a given user + note text not null default '', -- Tiny Tiny RSS freeform user note + primary key(article,subscription) -- no more than one mark-set per article per user ); insert into arsse_marks(article,subscription,read,starred,modified) select article,subscription,read,starred,modified from arsse_marks_old; drop table arsse_marks_old; -- set version marker pragma user_version = 2; -update arsse_meta set value = '2' where key = 'schema_version'; \ No newline at end of file +update arsse_meta set value = '2' where key = 'schema_version'; diff --git a/sql/SQLite3/2.sql b/sql/SQLite3/2.sql index 87f21ef..7340290 100644 --- a/sql/SQLite3/2.sql +++ b/sql/SQLite3/2.sql @@ -2,94 +2,106 @@ -- Copyright 2017 J. King, Dustin Wilson et al. -- See LICENSE and AUTHORS files for details --- Correct collation sequences +-- Correct collation sequences in order for various things to sort case-insensitively +-- SQLite has limited ALTER TABLE support, so the tables must be re-created +-- and their data re-entered; other database systems have a much simpler prodecure alter table arsse_users rename to arsse_users_old; create table arsse_users( - id text primary key not null collate nocase, - password text, - name text collate nocase, - avatar_type text, - avatar_data blob, - admin boolean default 0, - rights integer not null default 0 +-- users + id text primary key not null collate nocase, -- user id + password text, -- password, salted and hashed; if using external authentication this would be blank + name text collate nocase, -- display name + avatar_type text, -- internal avatar image's MIME content type + avatar_data blob, -- internal avatar image's binary data + admin boolean default 0, -- whether the user is a member of the special "admin" group + rights integer not null default 0 -- temporary admin-rights marker FIXME: remove reliance on this ); insert into arsse_users(id,password,name,avatar_type,avatar_data,admin,rights) select id,password,name,avatar_type,avatar_data,admin,rights from arsse_users_old; drop table arsse_users_old; alter table arsse_folders rename to arsse_folders_old; create table arsse_folders( - id integer primary key, - owner text not null references arsse_users(id) on delete cascade on update cascade, - parent integer references arsse_folders(id) on delete cascade, - name text not null collate nocase, - modified text not null default CURRENT_TIMESTAMP, -- - unique(owner,name,parent) +-- folders, used by NextCloud News and Tiny Tiny RSS +-- feed subscriptions may belong to at most one folder; +-- in Tiny Tiny RSS folders may nest + id integer primary key, -- sequence number + owner text not null references arsse_users(id) on delete cascade on update cascade, -- owner of folder + parent integer references arsse_folders(id) on delete cascade, -- parent folder id + name text not null collate nocase, -- folder name + modified text not null default CURRENT_TIMESTAMP, -- time at which the folder itself (not its contents) was changed; not currently used + unique(owner,name,parent) -- cannot have multiple folders with the same name under the same parent for the same owner ); insert into arsse_folders select * from arsse_folders_old; drop table arsse_folders_old; alter table arsse_feeds rename to arsse_feeds_old; create table arsse_feeds( - id integer primary key, - url text not null, - title text collate nocase, - favicon text, - source text, - updated text, - modified text, - next_fetch text, - orphaned text, - etag text not null default '', - err_count integer not null default 0, - err_msg text, - username text not null default '', - password text not null default '', - size integer not null default 0, - scrape boolean not null default 0, - unique(url,username,password) +-- newsfeeds, deduplicated +-- users have subscriptions to these feeds in another table + id integer primary key, -- sequence number + url text not null, -- URL of feed + title text collate nocase, -- default title of feed (users can set the title of their subscription to the feed) + favicon text, -- URL of favicon + source text, -- URL of site to which the feed belongs + updated text, -- time at which the feed was last fetched + modified text, -- time at which the feed last actually changed + next_fetch text, -- time at which the feed should next be fetched + orphaned text, -- time at which the feed last had no subscriptions + etag text not null default '', -- HTTP ETag hash used for cache validation, changes each time the content changes + err_count integer not null default 0, -- count of successive times update resulted in error since last successful update + err_msg text, -- last error message + username text not null default '', -- HTTP authentication username + password text not null default '', -- HTTP authentication password (this is stored in plain text) + size integer not null default 0, -- number of articles in the feed at last fetch + scrape boolean not null default 0, -- whether to use picoFeed's content scraper with this feed + unique(url,username,password) -- a URL with particular credentials should only appear once ); insert into arsse_feeds select * from arsse_feeds_old; drop table arsse_feeds_old; alter table arsse_subscriptions rename to arsse_subscriptions_old; create table arsse_subscriptions( - id integer primary key, - owner text not null references arsse_users(id) on delete cascade on update cascade, - feed integer not null references arsse_feeds(id) on delete cascade, - added text not null default CURRENT_TIMESTAMP, - modified text not null default CURRENT_TIMESTAMP, - title text collate nocase, - order_type int not null default 0, - pinned boolean not null default 0, - folder integer references arsse_folders(id) on delete cascade, - unique(owner,feed) +-- users' subscriptions to newsfeeds, with settings + id integer primary key, -- sequence number + owner text not null references arsse_users(id) on delete cascade on update cascade, -- owner of subscription + feed integer not null references arsse_feeds(id) on delete cascade, -- feed for the subscription + added text not null default CURRENT_TIMESTAMP, -- time at which feed was added + modified text not null default CURRENT_TIMESTAMP, -- time at which subscription properties were last modified + title text collate nocase, -- user-supplied title + order_type int not null default 0, -- NextCloud sort order + pinned boolean not null default 0, -- whether feed is pinned (always sorts at top) + folder integer references arsse_folders(id) on delete cascade, -- TT-RSS category (nestable); the first-level category (which acts as NextCloud folder) is joined in when needed + unique(owner,feed) -- a given feed should only appear once for a given owner ); insert into arsse_subscriptions select * from arsse_subscriptions_old; drop table arsse_subscriptions_old; alter table arsse_articles rename to arsse_articles_old; create table arsse_articles( - id integer primary key, - feed integer not null references arsse_feeds(id) on delete cascade, - url text, - title text collate nocase, - author text collate nocase, - published text, - edited text, - modified text not null default CURRENT_TIMESTAMP, - content text, - guid text, - url_title_hash text not null, - url_content_hash text not null, - title_content_hash text not null +-- entries in newsfeeds + id integer primary key, -- sequence number + feed integer not null references arsse_feeds(id) on delete cascade, -- feed for the subscription + url text, -- URL of article + title text collate nocase, -- article title + author text collate nocase, -- author's name + published text, -- time of original publication + edited text, -- time of last edit by author + modified text not null default CURRENT_TIMESTAMP, -- time when article was last modified in database + content text, -- content, as (X)HTML + guid text, -- GUID + url_title_hash text not null, -- hash of URL + title; used when checking for updates and for identification if there is no guid. + url_content_hash text not null, -- hash of URL + content, enclosure URL, & content type; used when checking for updates and for identification if there is no guid. + title_content_hash text not null -- hash of title + content, enclosure URL, & content type; used when checking for updates and for identification if there is no guid. ); insert into arsse_articles select * from arsse_articles_old; drop table arsse_articles_old; alter table arsse_categories rename to arsse_categories_old; create table arsse_categories( - article integer not null references arsse_articles(id) on delete cascade, - name text collate nocase +-- author categories associated with newsfeed entries +-- these are not user-modifiable + article integer not null references arsse_articles(id) on delete cascade, -- article associated with the category + name text collate nocase -- freeform name of the category ); insert into arsse_categories select * from arsse_categories_old; drop table arsse_categories_old; @@ -97,10 +109,11 @@ drop table arsse_categories_old; alter table arsse_labels rename to arsse_labels_old; create table arsse_labels ( - id integer primary key, - owner text not null references arsse_users(id) on delete cascade on update cascade, - name text not null collate nocase, - modified text not null default CURRENT_TIMESTAMP, +-- user-defined article labels for Tiny Tiny RSS + id integer primary key, -- numeric ID + owner text not null references arsse_users(id) on delete cascade on update cascade, -- owning user + name text not null collate nocase, -- label text + modified text not null default CURRENT_TIMESTAMP, -- time at which the label was last modified unique(owner,name) ); insert into arsse_labels select * from arsse_labels_old; @@ -108,4 +121,4 @@ drop table arsse_labels_old; -- set version marker pragma user_version = 3; -update arsse_meta set value = '3' where key = 'schema_version'; \ No newline at end of file +update arsse_meta set value = '3' where key = 'schema_version'; diff --git a/sql/SQLite3/3.sql b/sql/SQLite3/3.sql new file mode 100644 index 0000000..bac79a8 --- /dev/null +++ b/sql/SQLite3/3.sql @@ -0,0 +1,27 @@ +-- SPDX-License-Identifier: MIT +-- Copyright 2017 J. King, Dustin Wilson et al. +-- See LICENSE and AUTHORS files for details + +-- allow marks to initially have a null date due to changes in how marks are first created +-- and also add a "touched" column to aid in tracking changes during the course of some transactions +alter table arsse_marks rename to arsse_marks_old; +create table arsse_marks( +-- users' actions on newsfeed entries + article integer not null references arsse_articles(id) on delete cascade, -- article associated with the marks + subscription integer not null references arsse_subscriptions(id) on delete cascade on update cascade, -- subscription associated with the marks; the subscription in turn belongs to a user + read boolean not null default 0, -- whether the article has been read + starred boolean not null default 0, -- whether the article is starred + modified text, -- time at which an article was last modified by a given user + note text not null default '', -- Tiny Tiny RSS freeform user note + touched boolean not null default 0, -- used to indicate a record has been modified during the course of some transactions + primary key(article,subscription) -- no more than one mark-set per article per user +); +insert into arsse_marks select article,subscription,read,starred,modified,note,0 from arsse_marks_old; +drop table arsse_marks_old; + +-- reindex anything which uses the nocase collation sequence; it has been replaced with a Unicode collation +reindex nocase; + +-- set version marker +pragma user_version = 4; +update arsse_meta set value = '4' where key = 'schema_version'; diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index 108e328..789767a 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -16,16 +16,17 @@ use Phake; /** @covers \JKingWeb\Arsse\CLI */ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { - public function setUp() { - $this->clearData(false); + self::clearData(false); + $this->cli = Phake::partialMock(CLI::class); + Phake::when($this->cli)->logError->thenReturn(null); } public function assertConsole(CLI $cli, string $command, int $exitStatus, string $output = "", bool $pattern = false) { $argv = \Clue\Arguments\split($command); $output = strlen($output) ? $output.\PHP_EOL : ""; if ($pattern) { - $this->expectOutputRegex($output); + $this->expectOutputRegex($output); } else { $this->expectOutputString($output); } @@ -45,13 +46,13 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { } public function testPrintVersion() { - $this->assertConsole(new CLI, "arsse.php --version", 0, Arsse::VERSION); + $this->assertConsole($this->cli, "arsse.php --version", 0, Arsse::VERSION); $this->assertLoaded(false); } /** @dataProvider provideHelpText */ public function testPrintHelp(string $cmd, string $name) { - $this->assertConsole(new CLI, $cmd, 0, str_replace("arsse.php", $name, CLI::USAGE)); + $this->assertConsole($this->cli, $cmd, 0, str_replace("arsse.php", $name, CLI::USAGE)); $this->assertLoaded(false); } @@ -65,13 +66,12 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { public function testStartTheDaemon() { $srv = Phake::mock(Service::class); - $cli = Phake::partialMock(CLI::class); Phake::when($srv)->watch->thenReturn(new \DateTimeImmutable); - Phake::when($cli)->getService->thenReturn($srv); - $this->assertConsole($cli, "arsse.php daemon", 0); + Phake::when($this->cli)->getService->thenReturn($srv); + $this->assertConsole($this->cli, "arsse.php daemon", 0); $this->assertLoaded(true); Phake::verify($srv)->watch(true); - Phake::verify($cli)->getService; + Phake::verify($this->cli)->getService; } /** @dataProvider provideFeedUpdates */ @@ -79,7 +79,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { Arsse::$db = Phake::mock(Database::class); Phake::when(Arsse::$db)->feedUpdate(1, true)->thenReturn(true); Phake::when(Arsse::$db)->feedUpdate(2, true)->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/", new \PicoFeed\Client\InvalidUrlException)); - $this->assertConsole(new CLI, $cmd, $exitStatus, $output); + $this->assertConsole($this->cli, $cmd, $exitStatus, $output); $this->assertLoaded(true); Phake::verify(Arsse::$db)->feedUpdate; } @@ -94,12 +94,11 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideDefaultConfigurationSaves */ public function testSaveTheDefaultConfiguration(string $cmd, int $exitStatus, string $file) { $conf = Phake::mock(Conf::class); - $cli = Phake::partialMock(CLI::class); Phake::when($conf)->exportFile("php://output", true)->thenReturn(true); Phake::when($conf)->exportFile("good.conf", true)->thenReturn(true); Phake::when($conf)->exportFile("bad.conf", true)->thenThrow(new \JKingWeb\Arsse\Conf\Exception("fileUnwritable")); - Phake::when($cli)->getConf->thenReturn($conf); - $this->assertConsole($cli, $cmd, $exitStatus); + Phake::when($this->cli)->getConf->thenReturn($conf); + $this->assertConsole($this->cli, $cmd, $exitStatus); $this->assertLoaded(false); Phake::verify($conf)->exportFile($file, true); } @@ -115,10 +114,10 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideUserList */ public function testListUsers(string $cmd, array $list, int $exitStatus, string $output) { - // Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead + // FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead Arsse::$user = $this->createMock(User::class); Arsse::$user->method("list")->willReturn($list); - $this->assertConsole(new CLI, $cmd, $exitStatus, $output); + $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } public function provideUserList() { @@ -134,7 +133,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideUserAdditions */ public function testAddAUser(string $cmd, int $exitStatus, string $output) { - // Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead + // FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead Arsse::$user = $this->createMock(User::class); Arsse::$user->method("add")->will($this->returnCallback(function($user, $pass = null) { switch ($user) { @@ -144,7 +143,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { return is_null($pass) ? "random password" : $pass; } })); - $this->assertConsole(new CLI, $cmd, $exitStatus, $output); + $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } public function provideUserAdditions() { @@ -157,7 +156,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideUserAuthentication */ public function testAuthenticateAUser(string $cmd, int $exitStatus, string $output) { - // Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead + // FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead Arsse::$user = $this->createMock(User::class); Arsse::$user->method("auth")->will($this->returnCallback(function($user, $pass) { return ( @@ -165,7 +164,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { ($user == "jane.doe@example.com" && $pass == "superman") ); })); - $this->assertConsole(new CLI, $cmd, $exitStatus, $output); + $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } public function provideUserAuthentication() { @@ -180,7 +179,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideUserRemovals */ public function testRemoveAUser(string $cmd, int $exitStatus, string $output) { - // Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead + // FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead Arsse::$user = $this->createMock(User::class); Arsse::$user->method("remove")->will($this->returnCallback(function($user) { if ($user == "john.doe@example.com") { @@ -188,7 +187,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { } throw new \JKingWeb\Arsse\User\Exception("doesNotExist"); })); - $this->assertConsole(new CLI, $cmd, $exitStatus, $output); + $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } public function provideUserRemovals() { @@ -200,7 +199,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideUserPasswordChanges */ public function testChangeAUserPassword(string $cmd, int $exitStatus, string $output) { - // Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead + // FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead Arsse::$user = $this->createMock(User::class); Arsse::$user->method("passwordSet")->will($this->returnCallback(function($user, $pass = null) { switch ($user) { @@ -210,7 +209,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { return is_null($pass) ? "random password" : $pass; } })); - $this->assertConsole(new CLI, $cmd, $exitStatus, $output); + $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } public function provideUserPasswordChanges() { diff --git a/tests/cases/Conf/TestConf.php b/tests/cases/Conf/TestConf.php index 5aa56d8..aab95b9 100644 --- a/tests/cases/Conf/TestConf.php +++ b/tests/cases/Conf/TestConf.php @@ -15,7 +15,7 @@ class TestConf extends \JKingWeb\Arsse\Test\AbstractTest { public static $path; public function setUp() { - $this->clearData(); + self::clearData(); self::$vfs = vfsStream::setup("root", null, [ 'confGood' => ' "xx");', 'confNotArray' => 'clearData(); + self::clearData(); } public function testLoadDefaultValues() { diff --git a/tests/lib/Database/Setup.php b/tests/cases/Database/Base.php similarity index 62% rename from tests/lib/Database/Setup.php rename to tests/cases/Database/Base.php index 0a98edf..d148793 100644 --- a/tests/lib/Database/Setup.php +++ b/tests/cases/Database/Base.php @@ -4,37 +4,85 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Test\Database; +namespace JKingWeb\Arsse\TestCase\Database; -use JKingWeb\Arsse\User\Driver as UserDriver; +use JKingWeb\Arsse\Test\Database; 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 JKingWeb\Arsse\Test\DatabaseInformation; use Phake; -trait Setup { - protected $drv; +abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { + use SeriesMiscellany; + use SeriesMeta; + use SeriesUser; + use SeriesSession; + use SeriesFolder; + use SeriesFeed; + use SeriesSubscription; + use SeriesArticle; + use SeriesLabel; + use SeriesCleanup; + + /** @var \JKingWeb\Arsse\Test\DatabaseInformation */ + protected static $dbInfo; + /** @var \JKingWeb\Arsse\Db\Driver */ + protected static $drv; + protected static $failureReason = ""; protected $primed = false; - public function setUp() { + abstract protected function nextID(string $table): int; + + protected function findTraitOfTest(string $test): string { + $class = new \ReflectionClass(self::class); + foreach ($class->getTraits() as $trait) { + if ($trait->hasMethod($test)) { + return $trait->getShortName(); + } + } + return $class->getShortName(); + } + + public static function setUpBeforeClass() { // establish a clean baseline - $this->clearData(); - $this->setConf(); - // configure and create the relevant database driver - $this->setUpDriver(); - // create the database interface with the suitable driver - Arsse::$db = new Database($this->drv); + static::clearData(); + // perform an initial connection to the database to reset its version to zero + // in the case of SQLite this will always be the case (we use a memory database), + // but other engines should clean up from potentially interrupted prior tests + static::$dbInfo = new DatabaseInformation(static::$implementation); + static::setConf(); + try { + static::$drv = new static::$dbInfo->driverClass; + } catch (\JKingWeb\Arsse\Db\Exception $e) { + static::$failureReason = $e->getMessage(); + return; + } + // wipe the database absolutely clean + (static::$dbInfo->razeFunction)(static::$drv); + // create the database interface with the suitable driver and apply the latest schema + Arsse::$db = new Database(static::$drv); + Arsse::$db->driverSchemaUpdate(); + } + + public function setUp() { + // get the name of the test's test series + $this->series = $this->findTraitofTest($this->getName()); + static::clearData(); + static::setConf(); + if (strlen(static::$failureReason)) { + $this->markTestSkipped(static::$failureReason); + } + Arsse::$db = new Database(static::$drv); Arsse::$db->driverSchemaUpdate(); // create a mock user manager Arsse::$user = Phake::mock(User::class); Phake::when(Arsse::$user)->authorize->thenReturn(true); - // call the additional setup method if it exists - if (method_exists($this, "setUpSeries")) { - $this->setUpSeries(); - } + // call the series-specific setup method + $setUp = "setUp".$this->series; + $this->$setUp(); // prime the database with series data if it hasn't already been done if (!$this->primed && isset($this->data)) { $this->primeDatabase($this->data); @@ -42,21 +90,36 @@ trait Setup { } public function tearDown() { - // call the additional teardiwn method if it exists - if (method_exists($this, "tearDownSeries")) { - $this->tearDownSeries(); - } + // call the series-specific teardown method + $this->series = $this->findTraitofTest($this->getName()); + $tearDown = "tearDown".$this->series; + $this->$tearDown(); // clean up $this->primed = false; - $this->drv = null; - $this->clearData(); + // call the database-specific table cleanup function + (static::$dbInfo->truncateFunction)(static::$drv); + // clear state + static::clearData(); } - public function primeDatabase(array $data, \JKingWeb\Arsse\Db\Driver $drv = null): bool { - $drv = $drv ?? $this->drv; + public static function tearDownAfterClass() { + // wipe the database absolutely clean + (static::$dbInfo->razeFunction)(static::$drv); + // clean up + static::$drv = null; + static::$dbInfo = null; + static::$failureReason = ""; + static::clearData(); + } + + public function primeDatabase(array $data): bool { + $drv = static::$drv; $tr = $drv->begin(); foreach ($data as $table => $info) { - $cols = implode(",", array_keys($info['columns'])); + $cols = array_map(function($v) { + return '"'.str_replace('"', '""', $v).'"'; + }, array_keys($info['columns'])); + $cols = implode(",", $cols); $bindings = array_values($info['columns']); $params = implode(",", array_fill(0, sizeof($info['columns']), "?")); $s = $drv->prepareArray("INSERT INTO $table($cols) values($params)", $bindings); @@ -69,21 +132,14 @@ trait Setup { return true; } - public function primeFile(string $file, array $data = null): bool { - $data = $data ?? $this->data; - $primed = $this->primed; - $drv = new \JKingWeb\Arsse\Db\SQLite3\Driver($file); - $drv->schemaUpdate(\JKingWeb\Arsse\Database::SCHEMA_VERSION); - $this->primeDatabase($data, $drv); - $this->primed = $primed; - return true; - } - public function compareExpectations(array $expected): bool { foreach ($expected as $table => $info) { - $cols = implode(",", array_keys($info['columns'])); + $cols = array_map(function($v) { + return '"'.str_replace('"', '""', $v).'"'; + }, array_keys($info['columns'])); + $cols = implode(",", $cols); $types = $info['columns']; - $data = $this->drv->prepare("SELECT $cols from $table")->run()->getAll(); + $data = static::$drv->prepare("SELECT $cols from $table")->run()->getAll(); $cols = array_keys($info['columns']); foreach ($info['rows'] as $index => $row) { $this->assertCount(sizeof($cols), $row, "The number of values for array index $index does not match the number of fields"); @@ -169,7 +225,7 @@ trait Setup { $found = array_search($row, $expected); unset($expected[$found]); } - $this->assertArraySubset($expected, [], "Expectations not in result set."); + $this->assertArraySubset($expected, [], false, "Expectations not in result set."); } } } diff --git a/tests/lib/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php similarity index 51% rename from tests/lib/Database/SeriesArticle.php rename to tests/cases/Database/SeriesArticle.php index 7ffae2d..c3c4425 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -4,7 +4,7 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Test\Database; +namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Arsse; @@ -13,463 +13,487 @@ use JKingWeb\Arsse\Misc\Date; use Phake; trait SeriesArticle { - protected $data = [ - 'arsse_users' => [ - 'columns' => [ - 'id' => 'str', - 'password' => 'str', - 'name' => 'str', + protected function setUpSeriesArticle() { + $this->data = [ + 'arsse_users' => [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + 'name' => 'str', + ], + 'rows' => [ + ["jane.doe@example.com", "", "Jane Doe"], + ["john.doe@example.com", "", "John Doe"], + ["john.doe@example.org", "", "John Doe"], + ["john.doe@example.net", "", "John Doe"], + ], ], - 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], - ["john.doe@example.org", "", "John Doe"], - ["john.doe@example.net", "", "John Doe"], + 'arsse_folders' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'parent' => "int", + 'name' => "str", + ], + 'rows' => [ + [1, "john.doe@example.com", null, "Technology"], + [2, "john.doe@example.com", 1, "Software"], + [3, "john.doe@example.com", 1, "Rocketry"], + [4, "jane.doe@example.com", null, "Politics"], + [5, "john.doe@example.com", null, "Politics"], + [6, "john.doe@example.com", 2, "Politics"], + [7, "john.doe@example.net", null, "Technology"], + [8, "john.doe@example.net", 7, "Software"], + [9, "john.doe@example.net", null, "Politics"], + ] ], - ], - 'arsse_folders' => [ - 'columns' => [ - 'id' => "int", - 'owner' => "str", - 'parent' => "int", - 'name' => "str", + 'arsse_feeds' => [ + 'columns' => [ + 'id' => "int", + 'url' => "str", + 'title' => "str", + ], + 'rows' => [ + [1,"http://example.com/1", "Feed 1"], + [2,"http://example.com/2", "Feed 2"], + [3,"http://example.com/3", "Feed 3"], + [4,"http://example.com/4", "Feed 4"], + [5,"http://example.com/5", "Feed 5"], + [6,"http://example.com/6", "Feed 6"], + [7,"http://example.com/7", "Feed 7"], + [8,"http://example.com/8", "Feed 8"], + [9,"http://example.com/9", "Feed 9"], + [10,"http://example.com/10", "Feed 10"], + [11,"http://example.com/11", "Feed 11"], + [12,"http://example.com/12", "Feed 12"], + [13,"http://example.com/13", "Feed 13"], + ] ], - 'rows' => [ - [1, "john.doe@example.com", null, "Technology"], - [2, "john.doe@example.com", 1, "Software"], - [3, "john.doe@example.com", 1, "Rocketry"], - [4, "jane.doe@example.com", null, "Politics"], - [5, "john.doe@example.com", null, "Politics"], - [6, "john.doe@example.com", 2, "Politics"], - [7, "john.doe@example.net", null, "Technology"], - [8, "john.doe@example.net", 7, "Software"], - [9, "john.doe@example.net", null, "Politics"], - ] - ], - 'arsse_feeds' => [ - 'columns' => [ - 'id' => "int", - 'url' => "str", - 'title' => "str", + 'arsse_subscriptions' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'feed' => "int", + 'folder' => "int", + 'title' => "str", + ], + 'rows' => [ + [1, "john.doe@example.com",1, null,"Subscription 1"], + [2, "john.doe@example.com",2, null,null], + [3, "john.doe@example.com",3, 1,"Subscription 3"], + [4, "john.doe@example.com",4, 6,null], + [5, "john.doe@example.com",10, 5,"Subscription 5"], + [6, "jane.doe@example.com",1, null,null], + [7, "jane.doe@example.com",10,null,"Subscription 7"], + [8, "john.doe@example.org",11,null,null], + [9, "john.doe@example.org",12,null,"Subscription 9"], + [10,"john.doe@example.org",13,null,null], + [11,"john.doe@example.net",10,null,"Subscription 11"], + [12,"john.doe@example.net",2, 9,null], + [13,"john.doe@example.net",3, 8,"Subscription 13"], + [14,"john.doe@example.net",4, 7,null], + ] ], - 'rows' => [ - [1,"http://example.com/1", "Feed 1"], - [2,"http://example.com/2", "Feed 2"], - [3,"http://example.com/3", "Feed 3"], - [4,"http://example.com/4", "Feed 4"], - [5,"http://example.com/5", "Feed 5"], - [6,"http://example.com/6", "Feed 6"], - [7,"http://example.com/7", "Feed 7"], - [8,"http://example.com/8", "Feed 8"], - [9,"http://example.com/9", "Feed 9"], - [10,"http://example.com/10", "Feed 10"], - [11,"http://example.com/11", "Feed 11"], - [12,"http://example.com/12", "Feed 12"], - [13,"http://example.com/13", "Feed 13"], - ] - ], - 'arsse_subscriptions' => [ - 'columns' => [ - 'id' => "int", - 'owner' => "str", - 'feed' => "int", - 'folder' => "int", - 'title' => "str", + 'arsse_articles' => [ + 'columns' => [ + 'id' => "int", + 'feed' => "int", + 'url' => "str", + 'title' => "str", + 'author' => "str", + 'published' => "datetime", + 'edited' => "datetime", + 'content' => "str", + 'guid' => "str", + 'url_title_hash' => "str", + 'url_content_hash' => "str", + 'title_content_hash' => "str", + 'modified' => "datetime", + ], + 'rows' => [ + [1,1,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [2,1,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [3,2,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [4,2,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [5,3,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [6,3,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [7,4,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [8,4,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [9,5,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [10,5,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [11,6,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [12,6,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [13,7,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [14,7,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [15,8,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [16,8,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [17,9,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [18,9,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [19,10,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [20,10,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [101,11,'http://example.com/1','Article title 1','','2000-01-01 00:00:00','2000-01-01 00:00:01','

Article content 1

','e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda','f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6','fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4','18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207','2000-01-01 01:00:00'], + [102,11,'http://example.com/2','Article title 2','','2000-01-02 00:00:00','2000-01-02 00:00:02','

Article content 2

','5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7','0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153','13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9','2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e','2000-01-02 02:00:00'], + [103,12,'http://example.com/3','Article title 3','','2000-01-03 00:00:00','2000-01-03 00:00:03','

Article content 3

','31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92','f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b','b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406','ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b','2000-01-03 03:00:00'], + [104,12,'http://example.com/4','Article title 4','','2000-01-04 00:00:00','2000-01-04 00:00:04','

Article content 4

','804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180','f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8','f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3','ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9','2000-01-04 04:00:00'], + [105,13,'http://example.com/5','Article title 5','','2000-01-05 00:00:00','2000-01-05 00:00:05','

Article content 5

','db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41','d40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022','834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900','43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba','2000-01-05 05:00:00'], + ] ], - 'rows' => [ - [1, "john.doe@example.com",1, null,"Subscription 1"], - [2, "john.doe@example.com",2, null,null], - [3, "john.doe@example.com",3, 1,"Subscription 3"], - [4, "john.doe@example.com",4, 6,null], - [5, "john.doe@example.com",10, 5,"Subscription 5"], - [6, "jane.doe@example.com",1, null,null], - [7, "jane.doe@example.com",10,null,"Subscription 7"], - [8, "john.doe@example.org",11,null,null], - [9, "john.doe@example.org",12,null,"Subscription 9"], - [10,"john.doe@example.org",13,null,null], - [11,"john.doe@example.net",10,null,"Subscription 11"], - [12,"john.doe@example.net",2, 9,null], - [13,"john.doe@example.net",3, 8,"Subscription 13"], - [14,"john.doe@example.net",4, 7,null], - ] - ], - 'arsse_articles' => [ - 'columns' => [ - 'id' => "int", - 'feed' => "int", - 'url' => "str", - 'title' => "str", - 'author' => "str", - 'published' => "datetime", - 'edited' => "datetime", - 'content' => "str", - 'guid' => "str", - 'url_title_hash' => "str", - 'url_content_hash' => "str", - 'title_content_hash' => "str", - 'modified' => "datetime", + 'arsse_enclosures' => [ + 'columns' => [ + 'article' => "int", + 'url' => "str", + 'type' => "str", + ], + 'rows' => [ + [102,"http://example.com/text","text/plain"], + [103,"http://example.com/video","video/webm"], + [104,"http://example.com/image","image/svg+xml"], + [105,"http://example.com/audio","audio/ogg"], + + ] ], - 'rows' => [ - [1,1,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [2,1,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [3,2,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [4,2,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [5,3,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [6,3,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [7,4,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [8,4,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [9,5,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [10,5,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [11,6,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [12,6,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [13,7,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [14,7,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [15,8,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [16,8,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [17,9,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [18,9,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [19,10,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [20,10,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [101,11,'http://example.com/1','Article title 1','','2000-01-01 00:00:00','2000-01-01 00:00:01','

Article content 1

','e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda','f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6','fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4','18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207','2000-01-01 01:00:00'], - [102,11,'http://example.com/2','Article title 2','','2000-01-02 00:00:00','2000-01-02 00:00:02','

Article content 2

','5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7','0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153','13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9','2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e','2000-01-02 02:00:00'], - [103,12,'http://example.com/3','Article title 3','','2000-01-03 00:00:00','2000-01-03 00:00:03','

Article content 3

','31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92','f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b','b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406','ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b','2000-01-03 03:00:00'], - [104,12,'http://example.com/4','Article title 4','','2000-01-04 00:00:00','2000-01-04 00:00:04','

Article content 4

','804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180','f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8','f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3','ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9','2000-01-04 04:00:00'], - [105,13,'http://example.com/5','Article title 5','','2000-01-05 00:00:00','2000-01-05 00:00:05','

Article content 5

','db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41','d40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022','834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900','43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba','2000-01-05 05:00:00'], - ] - ], - 'arsse_enclosures' => [ - 'columns' => [ - 'article' => "int", - 'url' => "str", - 'type' => "str", + 'arsse_editions' => [ + 'columns' => [ + 'id' => "int", + 'article' => "int", + ], + 'rows' => [ + [1,1], + [2,2], + [3,3], + [4,4], + [5,5], + [6,6], + [7,7], + [8,8], + [9,9], + [10,10], + [11,11], + [12,12], + [13,13], + [14,14], + [15,15], + [16,16], + [17,17], + [18,18], + [19,19], + [20,20], + [101,101], + [102,102], + [103,103], + [104,104], + [105,105], + [202,102], + [203,103], + [204,104], + [205,105], + [305,105], + [1001,20], + ] ], - 'rows' => [ - [102,"http://example.com/text","text/plain"], - [103,"http://example.com/video","video/webm"], - [104,"http://example.com/image","image/svg+xml"], - [105,"http://example.com/audio","audio/ogg"], - - ] - ], - 'arsse_editions' => [ - 'columns' => [ - 'id' => "int", - 'article' => "int", + 'arsse_marks' => [ + 'columns' => [ + 'subscription' => "int", + 'article' => "int", + 'read' => "bool", + 'starred' => "bool", + 'modified' => "datetime", + 'note' => "str", + ], + 'rows' => [ + [1, 1,1,1,'2000-01-01 00:00:00',''], + [5, 19,1,0,'2016-01-01 00:00:00',''], + [5, 20,0,1,'2005-01-01 00:00:00',''], + [7, 20,1,0,'2010-01-01 00:00:00',''], + [8, 102,1,0,'2000-01-02 02:00:00','Note 2'], + [9, 103,0,1,'2000-01-03 03:00:00','Note 3'], + [9, 104,1,1,'2000-01-04 04:00:00','Note 4'], + [10,105,0,0,'2000-01-05 05:00:00',''], + [11, 19,0,0,'2017-01-01 00:00:00','ook'], + [11, 20,1,0,'2017-01-01 00:00:00','eek'], + [12, 3,0,1,'2017-01-01 00:00:00','ack'], + [12, 4,1,1,'2017-01-01 00:00:00','ach'], + [1, 2,0,0,'2010-01-01 00:00:00','Some Note'], + ] ], - 'rows' => [ - [1,1], - [2,2], - [3,3], - [4,4], - [5,5], - [6,6], - [7,7], - [8,8], - [9,9], - [10,10], - [11,11], - [12,12], - [13,13], - [14,14], - [15,15], - [16,16], - [17,17], - [18,18], - [19,19], - [20,20], - [101,101], - [102,102], - [103,103], - [104,104], - [105,105], - [202,102], - [203,103], - [204,104], - [205,105], - [305,105], - [1001,20], - ] - ], - 'arsse_marks' => [ - 'columns' => [ - 'subscription' => "int", - 'article' => "int", - 'read' => "bool", - 'starred' => "bool", - 'modified' => "datetime", - 'note' => "str", + 'arsse_categories' => [ // author-supplied categories + 'columns' => [ + 'article' => "int", + 'name' => "str", + ], + 'rows' => [ + [19,"Fascinating"], + [19,"Logical"], + [20,"Interesting"], + [20,"Logical"], + ], ], - 'rows' => [ - [1, 1,1,1,'2000-01-01 00:00:00',''], - [5, 19,1,0,'2016-01-01 00:00:00',''], - [5, 20,0,1,'2005-01-01 00:00:00',''], - [7, 20,1,0,'2010-01-01 00:00:00',''], - [8, 102,1,0,'2000-01-02 02:00:00','Note 2'], - [9, 103,0,1,'2000-01-03 03:00:00','Note 3'], - [9, 104,1,1,'2000-01-04 04:00:00','Note 4'], - [10,105,0,0,'2000-01-05 05:00:00',''], - [11, 19,0,0,'2017-01-01 00:00:00','ook'], - [11, 20,1,0,'2017-01-01 00:00:00','eek'], - [12, 3,0,1,'2017-01-01 00:00:00','ack'], - [12, 4,1,1,'2017-01-01 00:00:00','ach'], - [1, 2,0,0,'2010-01-01 00:00:00','Some Note'], - ] - ], - 'arsse_categories' => [ // author-supplied categories - 'columns' => [ - 'article' => "int", - 'name' => "str", + 'arsse_labels' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'name' => "str", + ], + 'rows' => [ + [1,"john.doe@example.com","Interesting"], + [2,"john.doe@example.com","Fascinating"], + [3,"jane.doe@example.com","Boring"], + [4,"john.doe@example.com","Lonely"], + ], ], - 'rows' => [ - [19,"Fascinating"], - [19,"Logical"], - [20,"Interesting"], - [20,"Logical"], + 'arsse_label_members' => [ + 'columns' => [ + 'label' => "int", + 'article' => "int", + 'subscription' => "int", + 'assigned' => "bool", + 'modified' => "datetime", + ], + 'rows' => [ + [1, 1,1,1,'2000-01-01 00:00:00'], + [2, 1,1,1,'2000-01-01 00:00:00'], + [1,19,5,1,'2000-01-01 00:00:00'], + [2,20,5,1,'2000-01-01 00:00:00'], + [1, 5,3,0,'2000-01-01 00:00:00'], + [2, 5,3,1,'2000-01-01 00:00:00'], + [4, 7,4,0,'2000-01-01 00:00:00'], + [4, 8,4,1,'2015-01-01 00:00:00'], + ], ], - ], - 'arsse_labels' => [ - 'columns' => [ - 'id' => "int", - 'owner' => "str", - 'name' => "str", + ]; + $this->matches = [ + [ + 'id' => 101, + 'url' => 'http://example.com/1', + 'title' => 'Article title 1', + 'subscription_title' => "Feed 11", + 'author' => '', + 'content' => '

Article content 1

', + 'guid' => 'e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda', + 'published_date' => '2000-01-01 00:00:00', + 'edited_date' => '2000-01-01 00:00:01', + 'modified_date' => '2000-01-01 01:00:00', + 'unread' => 1, + 'starred' => 0, + 'edition' => 101, + 'subscription' => 8, + 'fingerprint' => 'f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6:fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4:18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207', + 'media_url' => null, + 'media_type' => null, + 'note' => "", ], - 'rows' => [ - [1,"john.doe@example.com","Interesting"], - [2,"john.doe@example.com","Fascinating"], - [3,"jane.doe@example.com","Boring"], - [4,"john.doe@example.com","Lonely"], + [ + 'id' => 102, + 'url' => 'http://example.com/2', + 'title' => 'Article title 2', + 'subscription_title' => "Feed 11", + 'author' => '', + 'content' => '

Article content 2

', + 'guid' => '5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7', + 'published_date' => '2000-01-02 00:00:00', + 'edited_date' => '2000-01-02 00:00:02', + 'modified_date' => '2000-01-02 02:00:00', + 'unread' => 0, + 'starred' => 0, + 'edition' => 202, + 'subscription' => 8, + 'fingerprint' => '0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153:13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9:2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e', + 'media_url' => "http://example.com/text", + 'media_type' => "text/plain", + 'note' => "Note 2", ], - ], - 'arsse_label_members' => [ - 'columns' => [ - 'label' => "int", - 'article' => "int", - 'subscription' => "int", - 'assigned' => "bool", - 'modified' => "datetime", + [ + 'id' => 103, + 'url' => 'http://example.com/3', + 'title' => 'Article title 3', + 'subscription_title' => "Subscription 9", + 'author' => '', + 'content' => '

Article content 3

', + 'guid' => '31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92', + 'published_date' => '2000-01-03 00:00:00', + 'edited_date' => '2000-01-03 00:00:03', + 'modified_date' => '2000-01-03 03:00:00', + 'unread' => 1, + 'starred' => 1, + 'edition' => 203, + 'subscription' => 9, + 'fingerprint' => 'f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b:b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406:ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b', + 'media_url' => "http://example.com/video", + 'media_type' => "video/webm", + 'note' => "Note 3", ], - 'rows' => [ - [1, 1,1,1,'2000-01-01 00:00:00'], - [2, 1,1,1,'2000-01-01 00:00:00'], - [1,19,5,1,'2000-01-01 00:00:00'], - [2,20,5,1,'2000-01-01 00:00:00'], - [1, 5,3,0,'2000-01-01 00:00:00'], - [2, 5,3,1,'2000-01-01 00:00:00'], - [4, 7,4,0,'2000-01-01 00:00:00'], - [4, 8,4,1,'2015-01-01 00:00:00'], + [ + 'id' => 104, + 'url' => 'http://example.com/4', + 'title' => 'Article title 4', + 'subscription_title' => "Subscription 9", + 'author' => '', + 'content' => '

Article content 4

', + 'guid' => '804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180', + 'published_date' => '2000-01-04 00:00:00', + 'edited_date' => '2000-01-04 00:00:04', + 'modified_date' => '2000-01-04 04:00:00', + 'unread' => 0, + 'starred' => 1, + 'edition' => 204, + 'subscription' => 9, + 'fingerprint' => 'f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8:f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3:ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9', + 'media_url' => "http://example.com/image", + 'media_type' => "image/svg+xml", + 'note' => "Note 4", ], - ], - ]; - protected $matches = [ - [ - 'id' => 101, - 'url' => 'http://example.com/1', - 'title' => 'Article title 1', - 'subscription_title' => "Feed 11", - 'author' => '', - 'content' => '

Article content 1

', - 'guid' => 'e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda', - 'published_date' => '2000-01-01 00:00:00', - 'edited_date' => '2000-01-01 00:00:01', - 'modified_date' => '2000-01-01 01:00:00', - 'unread' => 1, - 'starred' => 0, - 'edition' => 101, - 'subscription' => 8, - 'fingerprint' => 'f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6:fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4:18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207', - 'media_url' => null, - 'media_type' => null, - 'note' => "", - ], - [ - 'id' => 102, - 'url' => 'http://example.com/2', - 'title' => 'Article title 2', - 'subscription_title' => "Feed 11", - 'author' => '', - 'content' => '

Article content 2

', - 'guid' => '5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7', - 'published_date' => '2000-01-02 00:00:00', - 'edited_date' => '2000-01-02 00:00:02', - 'modified_date' => '2000-01-02 02:00:00', - 'unread' => 0, - 'starred' => 0, - 'edition' => 202, - 'subscription' => 8, - 'fingerprint' => '0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153:13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9:2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e', - 'media_url' => "http://example.com/text", - 'media_type' => "text/plain", - 'note' => "Note 2", - ], - [ - 'id' => 103, - 'url' => 'http://example.com/3', - 'title' => 'Article title 3', - 'subscription_title' => "Subscription 9", - 'author' => '', - 'content' => '

Article content 3

', - 'guid' => '31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92', - 'published_date' => '2000-01-03 00:00:00', - 'edited_date' => '2000-01-03 00:00:03', - 'modified_date' => '2000-01-03 03:00:00', - 'unread' => 1, - 'starred' => 1, - 'edition' => 203, - 'subscription' => 9, - 'fingerprint' => 'f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b:b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406:ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b', - 'media_url' => "http://example.com/video", - 'media_type' => "video/webm", - 'note' => "Note 3", - ], - [ - 'id' => 104, - 'url' => 'http://example.com/4', - 'title' => 'Article title 4', - 'subscription_title' => "Subscription 9", - 'author' => '', - 'content' => '

Article content 4

', - 'guid' => '804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180', - 'published_date' => '2000-01-04 00:00:00', - 'edited_date' => '2000-01-04 00:00:04', - 'modified_date' => '2000-01-04 04:00:00', - 'unread' => 0, - 'starred' => 1, - 'edition' => 204, - 'subscription' => 9, - 'fingerprint' => 'f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8:f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3:ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9', - 'media_url' => "http://example.com/image", - 'media_type' => "image/svg+xml", - 'note' => "Note 4", - ], - [ - 'id' => 105, - 'url' => 'http://example.com/5', - 'title' => 'Article title 5', - 'subscription_title' => "Feed 13", - 'author' => '', - 'content' => '

Article content 5

', - 'guid' => 'db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41', - 'published_date' => '2000-01-05 00:00:00', - 'edited_date' => '2000-01-05 00:00:05', - 'modified_date' => '2000-01-05 05:00:00', - 'unread' => 1, - 'starred' => 0, - 'edition' => 305, - 'subscription' => 10, - 'fingerprint' => 'd40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022:834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900:43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba', - 'media_url' => "http://example.com/audio", - 'media_type' => "audio/ogg", - 'note' => "", - ], - ]; - protected $fields = [ - Database::LIST_MINIMAL => [ - "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date", - ], - Database::LIST_CONSERVATIVE => [ - "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date", - "url", "title", "subscription_title", "author", "guid", "published_date", "fingerprint", - ], - Database::LIST_TYPICAL => [ - "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date", - "url", "title", "subscription_title", "author", "guid", "published_date", "fingerprint", - "content", "media_url", "media_type", - ], - Database::LIST_FULL => [ + [ + 'id' => 105, + 'url' => 'http://example.com/5', + 'title' => 'Article title 5', + 'subscription_title' => "Feed 13", + 'author' => '', + 'content' => '

Article content 5

', + 'guid' => 'db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41', + 'published_date' => '2000-01-05 00:00:00', + 'edited_date' => '2000-01-05 00:00:05', + 'modified_date' => '2000-01-05 05:00:00', + 'unread' => 1, + 'starred' => 0, + 'edition' => 305, + 'subscription' => 10, + 'fingerprint' => 'd40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022:834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900:43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba', + 'media_url' => "http://example.com/audio", + 'media_type' => "audio/ogg", + 'note' => "", + ], + ]; + $this->fields = [ "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date", "url", "title", "subscription_title", "author", "guid", "published_date", "fingerprint", "content", "media_url", "media_type", "note", - ], - ]; - - public function setUpSeries() { + ]; $this->checkTables = ['arsse_marks' => ["subscription","article","read","starred","modified","note"],]; $this->user = "john.doe@example.net"; } - protected function compareIds(array $exp, Context $c) { - $ids = array_column($ids = Arsse::$db->articleList($this->user, $c)->getAll(), "id"); - sort($ids); - sort($exp); - $this->assertEquals($exp, $ids); + protected function tearDownSeriesArticle() { + unset($this->data, $this->matches, $this->fields, $this->checkTables, $this->user); + } + + public function testRetrieveArticleIdsForEditions() { + $exp = [ + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + 6 => 6, + 7 => 7, + 8 => 8, + 9 => 9, + 10 => 10, + 11 => 11, + 12 => 12, + 13 => 13, + 14 => 14, + 15 => 15, + 16 => 16, + 17 => 17, + 18 => 18, + 19 => 19, + 20 => 20, + 101 => 101, + 102 => 102, + 103 => 103, + 104 => 104, + 105 => 105, + 202 => 102, + 203 => 103, + 204 => 104, + 205 => 105, + 305 => 105, + 1001 => 20, + ]; + $this->assertEquals($exp, Arsse::$db->editionArticle(...range(1, 1001))); } public function testListArticlesCheckingContext() { - $this->user = "john.doe@example.com"; + $compareIds = function(array $exp, Context $c) { + $ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c)->getAll(), "id"); + sort($ids); + sort($exp); + $this->assertEquals($exp, $ids); + }; // get all items for user $exp = [1,2,3,4,5,6,7,8,19,20]; - $this->compareIds($exp, new Context); - $this->compareIds($exp, (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3))); + $compareIds($exp, new Context); + $compareIds($exp, (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3))); // get items from a folder tree - $this->compareIds([5,6,7,8], (new Context)->folder(1)); + $compareIds([5,6,7,8], (new Context)->folder(1)); // get items from a leaf folder - $this->compareIds([7,8], (new Context)->folder(6)); + $compareIds([7,8], (new Context)->folder(6)); // get items from a non-leaf folder without descending - $this->compareIds([1,2,3,4], (new Context)->folderShallow(0)); - $this->compareIds([5,6], (new Context)->folderShallow(1)); + $compareIds([1,2,3,4], (new Context)->folderShallow(0)); + $compareIds([5,6], (new Context)->folderShallow(1)); // get items from a single subscription $exp = [19,20]; - $this->compareIds($exp, (new Context)->subscription(5)); + $compareIds($exp, (new Context)->subscription(5)); // get un/read items from a single subscription - $this->compareIds([20], (new Context)->subscription(5)->unread(true)); - $this->compareIds([19], (new Context)->subscription(5)->unread(false)); + $compareIds([20], (new Context)->subscription(5)->unread(true)); + $compareIds([19], (new Context)->subscription(5)->unread(false)); // get starred articles - $this->compareIds([1,20], (new Context)->starred(true)); - $this->compareIds([2,3,4,5,6,7,8,19], (new Context)->starred(false)); - $this->compareIds([1], (new Context)->starred(true)->unread(false)); - $this->compareIds([], (new Context)->starred(true)->unread(false)->subscription(5)); + $compareIds([1,20], (new Context)->starred(true)); + $compareIds([2,3,4,5,6,7,8,19], (new Context)->starred(false)); + $compareIds([1], (new Context)->starred(true)->unread(false)); + $compareIds([], (new Context)->starred(true)->unread(false)->subscription(5)); // get items relative to edition - $this->compareIds([19], (new Context)->subscription(5)->latestEdition(999)); - $this->compareIds([19], (new Context)->subscription(5)->latestEdition(19)); - $this->compareIds([20], (new Context)->subscription(5)->oldestEdition(999)); - $this->compareIds([20], (new Context)->subscription(5)->oldestEdition(1001)); + $compareIds([19], (new Context)->subscription(5)->latestEdition(999)); + $compareIds([19], (new Context)->subscription(5)->latestEdition(19)); + $compareIds([20], (new Context)->subscription(5)->oldestEdition(999)); + $compareIds([20], (new Context)->subscription(5)->oldestEdition(1001)); // get items relative to article ID - $this->compareIds([1,2,3], (new Context)->latestArticle(3)); - $this->compareIds([19,20], (new Context)->oldestArticle(19)); + $compareIds([1,2,3], (new Context)->latestArticle(3)); + $compareIds([19,20], (new Context)->oldestArticle(19)); // get items relative to (feed) modification date $exp = [2,4,6,8,20]; - $this->compareIds($exp, (new Context)->modifiedSince("2005-01-01T00:00:00Z")); - $this->compareIds($exp, (new Context)->modifiedSince("2010-01-01T00:00:00Z")); + $compareIds($exp, (new Context)->modifiedSince("2005-01-01T00:00:00Z")); + $compareIds($exp, (new Context)->modifiedSince("2010-01-01T00:00:00Z")); $exp = [1,3,5,7,19]; - $this->compareIds($exp, (new Context)->notModifiedSince("2005-01-01T00:00:00Z")); - $this->compareIds($exp, (new Context)->notModifiedSince("2000-01-01T00:00:00Z")); + $compareIds($exp, (new Context)->notModifiedSince("2005-01-01T00:00:00Z")); + $compareIds($exp, (new Context)->notModifiedSince("2000-01-01T00:00:00Z")); // get items relative to (user) modification date (both marks and labels apply) - $this->compareIds([8,19], (new Context)->markedSince("2014-01-01T00:00:00Z")); - $this->compareIds([2,4,6,8,19,20], (new Context)->markedSince("2010-01-01T00:00:00Z")); - $this->compareIds([1,2,3,4,5,6,7,20], (new Context)->notMarkedSince("2014-01-01T00:00:00Z")); - $this->compareIds([1,3,5,7], (new Context)->notMarkedSince("2005-01-01T00:00:00Z")); + $compareIds([8,19], (new Context)->markedSince("2014-01-01T00:00:00Z")); + $compareIds([2,4,6,8,19,20], (new Context)->markedSince("2010-01-01T00:00:00Z")); + $compareIds([1,2,3,4,5,6,7,20], (new Context)->notMarkedSince("2014-01-01T00:00:00Z")); + $compareIds([1,3,5,7], (new Context)->notMarkedSince("2005-01-01T00:00:00Z")); // paged results - $this->compareIds([1], (new Context)->limit(1)); - $this->compareIds([2], (new Context)->limit(1)->oldestEdition(1+1)); - $this->compareIds([3], (new Context)->limit(1)->oldestEdition(2+1)); - $this->compareIds([4,5], (new Context)->limit(2)->oldestEdition(3+1)); + $compareIds([1], (new Context)->limit(1)); + $compareIds([2], (new Context)->limit(1)->oldestEdition(1+1)); + $compareIds([3], (new Context)->limit(1)->oldestEdition(2+1)); + $compareIds([4,5], (new Context)->limit(2)->oldestEdition(3+1)); // reversed results - $this->compareIds([20], (new Context)->reverse(true)->limit(1)); - $this->compareIds([19], (new Context)->reverse(true)->limit(1)->latestEdition(1001-1)); - $this->compareIds([8], (new Context)->reverse(true)->limit(1)->latestEdition(19-1)); - $this->compareIds([7,6], (new Context)->reverse(true)->limit(2)->latestEdition(8-1)); + $compareIds([20], (new Context)->reverse(true)->limit(1)); + $compareIds([19], (new Context)->reverse(true)->limit(1)->latestEdition(1001-1)); + $compareIds([8], (new Context)->reverse(true)->limit(1)->latestEdition(19-1)); + $compareIds([7,6], (new Context)->reverse(true)->limit(2)->latestEdition(8-1)); // get articles by label ID - $this->compareIds([1,19], (new Context)->label(1)); - $this->compareIds([1,5,20], (new Context)->label(2)); + $compareIds([1,19], (new Context)->label(1)); + $compareIds([1,5,20], (new Context)->label(2)); // get articles by label name - $this->compareIds([1,19], (new Context)->labelName("Interesting")); - $this->compareIds([1,5,20], (new Context)->labelName("Fascinating")); + $compareIds([1,19], (new Context)->labelName("Interesting")); + $compareIds([1,5,20], (new Context)->labelName("Fascinating")); // get articles with any or no label - $this->compareIds([1,5,8,19,20], (new Context)->labelled(true)); - $this->compareIds([2,3,4,6,7], (new Context)->labelled(false)); + $compareIds([1,5,8,19,20], (new Context)->labelled(true)); + $compareIds([2,3,4,6,7], (new Context)->labelled(false)); // get a specific article or edition - $this->compareIds([20], (new Context)->article(20)); - $this->compareIds([20], (new Context)->edition(1001)); + $compareIds([20], (new Context)->article(20)); + $compareIds([20], (new Context)->edition(1001)); // get multiple specific articles or editions - $this->compareIds([1,20], (new Context)->articles([1,20,50])); - $this->compareIds([1,20], (new Context)->editions([1,1001,50])); + $compareIds([1,20], (new Context)->articles([1,20,50])); + $compareIds([1,20], (new Context)->editions([1,1001,50])); // get articles base on whether or not they have notes - $this->compareIds([1,3,4,5,6,7,8,19,20], (new Context)->annotated(false)); - $this->compareIds([2], (new Context)->annotated(true)); + $compareIds([1,3,4,5,6,7,8,19,20], (new Context)->annotated(false)); + $compareIds([2], (new Context)->annotated(true)); // get specific starred articles - $this->compareIds([1], (new Context)->articles([1,2,3])->starred(true)); - $this->compareIds([2,3], (new Context)->articles([1,2,3])->starred(false)); + $compareIds([1], (new Context)->articles([1,2,3])->starred(true)); + $compareIds([2,3], (new Context)->articles([1,2,3])->starred(false)); } public function testListArticlesOfAMissingFolder() { @@ -484,17 +508,15 @@ trait SeriesArticle { public function testListArticlesCheckingProperties() { $this->user = "john.doe@example.org"; - $this->assertResult($this->matches, Arsse::$db->articleList($this->user)); // check that the different fieldset groups return the expected columns - foreach ($this->fields as $constant => $columns) { - $test = array_keys(Arsse::$db->articleList($this->user, (new Context)->article(101), $constant)->getRow()); - sort($columns); - sort($test); - $this->assertEquals($columns, $test, "Fields do not match expectation for verbosity $constant"); + foreach ($this->fields as $column) { + $test = array_keys(Arsse::$db->articleList($this->user, (new Context)->article(101), [$column])->getRow()); + $this->assertEquals([$column], $test); } - // check that an unknown fieldset produces an exception - $this->assertException("constantUnknown"); - Arsse::$db->articleList($this->user, (new Context)->article(101), \PHP_INT_MAX); + // check that an unknown field is silently ignored + $columns = array_merge($this->fields, ["unknown_column", "bogus_column"]); + $test = array_keys(Arsse::$db->articleList($this->user, (new Context)->article(101), $columns)->getRow()); + $this->assertEquals($this->fields, $test); } public function testListArticlesWithoutAuthority() { @@ -503,6 +525,10 @@ trait SeriesArticle { Arsse::$db->articleList($this->user); } + public function testMarkNothing() { + $this->assertSame(0, Arsse::$db->articleMark($this->user, [])); + } + public function testMarkAllArticlesUnread() { Arsse::$db->articleMark($this->user, ['read'=>false]); $now = Date::transform(time(), "sql"); @@ -746,6 +772,12 @@ trait SeriesArticle { $this->compareExpectations($state); } + public function testMarkMultipleMissingEditions() { + $this->assertSame(0, Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->editions([500,501]))); + $state = $this->primeExpectations($this->data, $this->checkTables); + $this->compareExpectations($state); + } + public function testMarkMultipleEditionsUnread() { Arsse::$db->articleMark($this->user, ['read'=>false], (new Context)->editions([2,4,7,1001])); $now = Date::transform(time(), "sql"); @@ -915,7 +947,7 @@ trait SeriesArticle { } public function testListTheLabelsOfAnArticle() { - $this->assertEquals([2,1], Arsse::$db->articleLabelsGet("john.doe@example.com", 1)); + $this->assertEquals([1,2], Arsse::$db->articleLabelsGet("john.doe@example.com", 1)); $this->assertEquals([2], Arsse::$db->articleLabelsGet("john.doe@example.com", 5)); $this->assertEquals([], Arsse::$db->articleLabelsGet("john.doe@example.com", 2)); $this->assertEquals(["Fascinating","Interesting"], Arsse::$db->articleLabelsGet("john.doe@example.com", 1, true)); diff --git a/tests/lib/Database/SeriesCleanup.php b/tests/cases/Database/SeriesCleanup.php similarity index 97% rename from tests/lib/Database/SeriesCleanup.php rename to tests/cases/Database/SeriesCleanup.php index 532c18d..b888686 100644 --- a/tests/lib/Database/SeriesCleanup.php +++ b/tests/cases/Database/SeriesCleanup.php @@ -4,13 +4,13 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Test\Database; +namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Arsse; use Phake; trait SeriesCleanup { - public function setUpSeries() { + protected function setUpSeriesCleanup() { // set up the configuration Arsse::$conf->import([ 'userSessionTimeout' => "PT1H", @@ -135,6 +135,10 @@ trait SeriesCleanup { ]; } + protected function tearDownSeriesCleanup() { + unset($this->data); + } + public function testCleanUpOrphanedFeeds() { Arsse::$db->feedCleanup(); $now = gmdate("Y-m-d H:i:s"); diff --git a/tests/lib/Database/SeriesFeed.php b/tests/cases/Database/SeriesFeed.php similarity index 90% rename from tests/lib/Database/SeriesFeed.php rename to tests/cases/Database/SeriesFeed.php index 24a0097..c7cd2a4 100644 --- a/tests/lib/Database/SeriesFeed.php +++ b/tests/cases/Database/SeriesFeed.php @@ -4,7 +4,7 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Test\Database; +namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Feed; @@ -12,26 +12,7 @@ use JKingWeb\Arsse\Feed\Exception as FeedException; use Phake; trait SeriesFeed { - protected $matches = [ - [ - 'id' => 4, - 'edited' => '2000-01-04 00:00:00', - 'guid' => '804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180', - 'url_title_hash' => 'f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8', - 'url_content_hash' => 'f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3', - 'title_content_hash' => 'ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9', - ], - [ - 'id' => 5, - 'edited' => '2000-01-05 00:00:00', - 'guid' => 'db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41', - 'url_title_hash' => 'd40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022', - 'url_content_hash' => '834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900', - 'title_content_hash' => '43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba', - ], - ]; - - public function setUpSeries() { + protected function setUpSeriesFeed() { // set up the test data $past = gmdate("Y-m-d H:i:s", strtotime("now - 1 minute")); $future = gmdate("Y-m-d H:i:s", strtotime("now + 1 minute")); @@ -163,6 +144,28 @@ trait SeriesFeed { ] ], ]; + $this->matches = [ + [ + 'id' => 4, + 'edited' => '2000-01-04 00:00:00', + 'guid' => '804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180', + 'url_title_hash' => 'f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8', + 'url_content_hash' => 'f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3', + 'title_content_hash' => 'ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9', + ], + [ + 'id' => 5, + 'edited' => '2000-01-05 00:00:00', + 'guid' => 'db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41', + 'url_title_hash' => 'd40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022', + 'url_content_hash' => '834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900', + 'title_content_hash' => '43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba', + ], + ]; + } + + protected function tearDownSeriesFeed() { + unset($this->data, $this->matches); } public function testListLatestItems() { diff --git a/tests/lib/Database/SeriesFolder.php b/tests/cases/Database/SeriesFolder.php similarity index 89% rename from tests/lib/Database/SeriesFolder.php rename to tests/cases/Database/SeriesFolder.php index d2d5b25..7265f07 100644 --- a/tests/lib/Database/SeriesFolder.php +++ b/tests/cases/Database/SeriesFolder.php @@ -4,51 +4,57 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Test\Database; +namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Arsse; use Phake; trait SeriesFolder { - protected $data = [ - 'arsse_users' => [ - 'columns' => [ - 'id' => 'str', - 'password' => 'str', - 'name' => 'str', + protected function setUpSeriesFolder() { + $this->data = [ + 'arsse_users' => [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + 'name' => 'str', + ], + 'rows' => [ + ["jane.doe@example.com", "", "Jane Doe"], + ["john.doe@example.com", "", "John Doe"], + ], ], - 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + 'arsse_folders' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'parent' => "int", + 'name' => "str", + ], + /* Layout translates to: + Jane + Politics + John + Technology + Software + Politics + Rocketry + Politics + */ + 'rows' => [ + [1, "john.doe@example.com", null, "Technology"], + [2, "john.doe@example.com", 1, "Software"], + [3, "john.doe@example.com", 1, "Rocketry"], + [4, "jane.doe@example.com", null, "Politics"], + [5, "john.doe@example.com", null, "Politics"], + [6, "john.doe@example.com", 2, "Politics"], + ] ], - ], - 'arsse_folders' => [ - 'columns' => [ - 'id' => "int", - 'owner' => "str", - 'parent' => "int", - 'name' => "str", - ], - /* Layout translates to: - Jane - Politics - John - Technology - Software - Politics - Rocketry - Politics - */ - 'rows' => [ - [1, "john.doe@example.com", null, "Technology"], - [2, "john.doe@example.com", 1, "Software"], - [3, "john.doe@example.com", 1, "Rocketry"], - [4, "jane.doe@example.com", null, "Politics"], - [5, "john.doe@example.com", null, "Politics"], - [6, "john.doe@example.com", 2, "Politics"], - ] - ], - ]; + ]; + } + + protected function tearDownSeriesFolder() { + unset($this->data); + } public function testAddARootFolder() { $user = "john.doe@example.com"; diff --git a/tests/lib/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php similarity index 52% rename from tests/lib/Database/SeriesLabel.php rename to tests/cases/Database/SeriesLabel.php index c764b04..8347ce5 100644 --- a/tests/lib/Database/SeriesLabel.php +++ b/tests/cases/Database/SeriesLabel.php @@ -4,7 +4,7 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Test\Database; +namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Misc\Context; @@ -12,241 +12,244 @@ use JKingWeb\Arsse\Misc\Date; use Phake; trait SeriesLabel { - protected $data = [ - 'arsse_users' => [ - 'columns' => [ - 'id' => 'str', - 'password' => 'str', - 'name' => 'str', + protected function setUpSeriesLabel() { + $this->data = [ + 'arsse_users' => [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + 'name' => 'str', + ], + 'rows' => [ + ["jane.doe@example.com", "", "Jane Doe"], + ["john.doe@example.com", "", "John Doe"], + ["john.doe@example.org", "", "John Doe"], + ["john.doe@example.net", "", "John Doe"], + ], ], - 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], - ["john.doe@example.org", "", "John Doe"], - ["john.doe@example.net", "", "John Doe"], + 'arsse_folders' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'parent' => "int", + 'name' => "str", + ], + 'rows' => [ + [1, "john.doe@example.com", null, "Technology"], + [2, "john.doe@example.com", 1, "Software"], + [3, "john.doe@example.com", 1, "Rocketry"], + [4, "jane.doe@example.com", null, "Politics"], + [5, "john.doe@example.com", null, "Politics"], + [6, "john.doe@example.com", 2, "Politics"], + [7, "john.doe@example.net", null, "Technology"], + [8, "john.doe@example.net", 7, "Software"], + [9, "john.doe@example.net", null, "Politics"], + ] ], - ], - 'arsse_folders' => [ - 'columns' => [ - 'id' => "int", - 'owner' => "str", - 'parent' => "int", - 'name' => "str", + 'arsse_feeds' => [ + 'columns' => [ + 'id' => "int", + 'url' => "str", + ], + 'rows' => [ + [1,"http://example.com/1"], + [2,"http://example.com/2"], + [3,"http://example.com/3"], + [4,"http://example.com/4"], + [5,"http://example.com/5"], + [6,"http://example.com/6"], + [7,"http://example.com/7"], + [8,"http://example.com/8"], + [9,"http://example.com/9"], + [10,"http://example.com/10"], + [11,"http://example.com/11"], + [12,"http://example.com/12"], + [13,"http://example.com/13"], + ] ], - 'rows' => [ - [1, "john.doe@example.com", null, "Technology"], - [2, "john.doe@example.com", 1, "Software"], - [3, "john.doe@example.com", 1, "Rocketry"], - [4, "jane.doe@example.com", null, "Politics"], - [5, "john.doe@example.com", null, "Politics"], - [6, "john.doe@example.com", 2, "Politics"], - [7, "john.doe@example.net", null, "Technology"], - [8, "john.doe@example.net", 7, "Software"], - [9, "john.doe@example.net", null, "Politics"], - ] - ], - 'arsse_feeds' => [ - 'columns' => [ - 'id' => "int", - 'url' => "str", + 'arsse_subscriptions' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'feed' => "int", + 'folder' => "int", + ], + 'rows' => [ + [1,"john.doe@example.com",1,null], + [2,"john.doe@example.com",2,null], + [3,"john.doe@example.com",3,1], + [4,"john.doe@example.com",4,6], + [5,"john.doe@example.com",10,5], + [6,"jane.doe@example.com",1,null], + [7,"jane.doe@example.com",10,null], + [8,"john.doe@example.org",11,null], + [9,"john.doe@example.org",12,null], + [10,"john.doe@example.org",13,null], + [11,"john.doe@example.net",10,null], + [12,"john.doe@example.net",2,9], + [13,"john.doe@example.net",3,8], + [14,"john.doe@example.net",4,7], + ] ], - 'rows' => [ - [1,"http://example.com/1"], - [2,"http://example.com/2"], - [3,"http://example.com/3"], - [4,"http://example.com/4"], - [5,"http://example.com/5"], - [6,"http://example.com/6"], - [7,"http://example.com/7"], - [8,"http://example.com/8"], - [9,"http://example.com/9"], - [10,"http://example.com/10"], - [11,"http://example.com/11"], - [12,"http://example.com/12"], - [13,"http://example.com/13"], - ] - ], - 'arsse_subscriptions' => [ - 'columns' => [ - 'id' => "int", - 'owner' => "str", - 'feed' => "int", - 'folder' => "int", + 'arsse_articles' => [ + 'columns' => [ + 'id' => "int", + 'feed' => "int", + 'url' => "str", + 'title' => "str", + 'author' => "str", + 'published' => "datetime", + 'edited' => "datetime", + 'content' => "str", + 'guid' => "str", + 'url_title_hash' => "str", + 'url_content_hash' => "str", + 'title_content_hash' => "str", + 'modified' => "datetime", + ], + 'rows' => [ + [1,1,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [2,1,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [3,2,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [4,2,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [5,3,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [6,3,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [7,4,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [8,4,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [9,5,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [10,5,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [11,6,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [12,6,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [13,7,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [14,7,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [15,8,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [16,8,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [17,9,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [18,9,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [19,10,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [20,10,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [101,11,'http://example.com/1','Article title 1','','2000-01-01 00:00:00','2000-01-01 00:00:01','

Article content 1

','e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda','f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6','fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4','18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207','2000-01-01 01:00:00'], + [102,11,'http://example.com/2','Article title 2','','2000-01-02 00:00:00','2000-01-02 00:00:02','

Article content 2

','5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7','0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153','13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9','2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e','2000-01-02 02:00:00'], + [103,12,'http://example.com/3','Article title 3','','2000-01-03 00:00:00','2000-01-03 00:00:03','

Article content 3

','31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92','f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b','b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406','ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b','2000-01-03 03:00:00'], + [104,12,'http://example.com/4','Article title 4','','2000-01-04 00:00:00','2000-01-04 00:00:04','

Article content 4

','804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180','f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8','f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3','ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9','2000-01-04 04:00:00'], + [105,13,'http://example.com/5','Article title 5','','2000-01-05 00:00:00','2000-01-05 00:00:05','

Article content 5

','db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41','d40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022','834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900','43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba','2000-01-05 05:00:00'], + ] ], - 'rows' => [ - [1,"john.doe@example.com",1,null], - [2,"john.doe@example.com",2,null], - [3,"john.doe@example.com",3,1], - [4,"john.doe@example.com",4,6], - [5,"john.doe@example.com",10,5], - [6,"jane.doe@example.com",1,null], - [7,"jane.doe@example.com",10,null], - [8,"john.doe@example.org",11,null], - [9,"john.doe@example.org",12,null], - [10,"john.doe@example.org",13,null], - [11,"john.doe@example.net",10,null], - [12,"john.doe@example.net",2,9], - [13,"john.doe@example.net",3,8], - [14,"john.doe@example.net",4,7], - ] - ], - 'arsse_articles' => [ - 'columns' => [ - 'id' => "int", - 'feed' => "int", - 'url' => "str", - 'title' => "str", - 'author' => "str", - 'published' => "datetime", - 'edited' => "datetime", - 'content' => "str", - 'guid' => "str", - 'url_title_hash' => "str", - 'url_content_hash' => "str", - 'title_content_hash' => "str", - 'modified' => "datetime", + 'arsse_enclosures' => [ + 'columns' => [ + 'article' => "int", + 'url' => "str", + 'type' => "str", + ], + 'rows' => [ + [102,"http://example.com/text","text/plain"], + [103,"http://example.com/video","video/webm"], + [104,"http://example.com/image","image/svg+xml"], + [105,"http://example.com/audio","audio/ogg"], + + ] ], - 'rows' => [ - [1,1,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [2,1,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [3,2,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [4,2,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [5,3,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [6,3,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [7,4,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [8,4,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [9,5,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [10,5,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [11,6,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [12,6,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [13,7,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [14,7,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [15,8,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [16,8,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [17,9,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [18,9,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [19,10,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [20,10,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [101,11,'http://example.com/1','Article title 1','','2000-01-01 00:00:00','2000-01-01 00:00:01','

Article content 1

','e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda','f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6','fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4','18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207','2000-01-01 01:00:00'], - [102,11,'http://example.com/2','Article title 2','','2000-01-02 00:00:00','2000-01-02 00:00:02','

Article content 2

','5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7','0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153','13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9','2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e','2000-01-02 02:00:00'], - [103,12,'http://example.com/3','Article title 3','','2000-01-03 00:00:00','2000-01-03 00:00:03','

Article content 3

','31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92','f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b','b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406','ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b','2000-01-03 03:00:00'], - [104,12,'http://example.com/4','Article title 4','','2000-01-04 00:00:00','2000-01-04 00:00:04','

Article content 4

','804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180','f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8','f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3','ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9','2000-01-04 04:00:00'], - [105,13,'http://example.com/5','Article title 5','','2000-01-05 00:00:00','2000-01-05 00:00:05','

Article content 5

','db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41','d40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022','834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900','43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba','2000-01-05 05:00:00'], - ] - ], - 'arsse_enclosures' => [ - 'columns' => [ - 'article' => "int", - 'url' => "str", - 'type' => "str", + 'arsse_editions' => [ + 'columns' => [ + 'id' => "int", + 'article' => "int", + ], + 'rows' => [ + [1,1], + [2,2], + [3,3], + [4,4], + [5,5], + [6,6], + [7,7], + [8,8], + [9,9], + [10,10], + [11,11], + [12,12], + [13,13], + [14,14], + [15,15], + [16,16], + [17,17], + [18,18], + [19,19], + [20,20], + [101,101], + [102,102], + [103,103], + [104,104], + [105,105], + [202,102], + [203,103], + [204,104], + [205,105], + [305,105], + [1001,20], + ] ], - 'rows' => [ - [102,"http://example.com/text","text/plain"], - [103,"http://example.com/video","video/webm"], - [104,"http://example.com/image","image/svg+xml"], - [105,"http://example.com/audio","audio/ogg"], - - ] - ], - 'arsse_editions' => [ - 'columns' => [ - 'id' => "int", - 'article' => "int", + 'arsse_marks' => [ + 'columns' => [ + 'subscription' => "int", + 'article' => "int", + 'read' => "bool", + 'starred' => "bool", + 'modified' => "datetime" + ], + 'rows' => [ + [1, 1,1,1,'2000-01-01 00:00:00'], + [5, 19,1,0,'2000-01-01 00:00:00'], + [5, 20,0,1,'2010-01-01 00:00:00'], + [7, 20,1,0,'2010-01-01 00:00:00'], + [8, 102,1,0,'2000-01-02 02:00:00'], + [9, 103,0,1,'2000-01-03 03:00:00'], + [9, 104,1,1,'2000-01-04 04:00:00'], + [10,105,0,0,'2000-01-05 05:00:00'], + [11, 19,0,0,'2017-01-01 00:00:00'], + [11, 20,1,0,'2017-01-01 00:00:00'], + [12, 3,0,1,'2017-01-01 00:00:00'], + [12, 4,1,1,'2017-01-01 00:00:00'], + ] ], - 'rows' => [ - [1,1], - [2,2], - [3,3], - [4,4], - [5,5], - [6,6], - [7,7], - [8,8], - [9,9], - [10,10], - [11,11], - [12,12], - [13,13], - [14,14], - [15,15], - [16,16], - [17,17], - [18,18], - [19,19], - [20,20], - [101,101], - [102,102], - [103,103], - [104,104], - [105,105], - [202,102], - [203,103], - [204,104], - [205,105], - [305,105], - [1001,20], - ] - ], - 'arsse_marks' => [ - 'columns' => [ - 'subscription' => "int", - 'article' => "int", - 'read' => "bool", - 'starred' => "bool", - 'modified' => "datetime" + 'arsse_labels' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'name' => "str", + ], + 'rows' => [ + [1,"john.doe@example.com","Interesting"], + [2,"john.doe@example.com","Fascinating"], + [3,"jane.doe@example.com","Boring"], + [4,"john.doe@example.com","Lonely"], + ], ], - 'rows' => [ - [1, 1,1,1,'2000-01-01 00:00:00'], - [5, 19,1,0,'2000-01-01 00:00:00'], - [5, 20,0,1,'2010-01-01 00:00:00'], - [7, 20,1,0,'2010-01-01 00:00:00'], - [8, 102,1,0,'2000-01-02 02:00:00'], - [9, 103,0,1,'2000-01-03 03:00:00'], - [9, 104,1,1,'2000-01-04 04:00:00'], - [10,105,0,0,'2000-01-05 05:00:00'], - [11, 19,0,0,'2017-01-01 00:00:00'], - [11, 20,1,0,'2017-01-01 00:00:00'], - [12, 3,0,1,'2017-01-01 00:00:00'], - [12, 4,1,1,'2017-01-01 00:00:00'], - ] - ], - 'arsse_labels' => [ - 'columns' => [ - 'id' => "int", - 'owner' => "str", - 'name' => "str", + 'arsse_label_members' => [ + 'columns' => [ + 'label' => "int", + 'article' => "int", + 'subscription' => "int", + 'assigned' => "bool", + ], + 'rows' => [ + [1, 1,1,1], + [2, 1,1,1], + [1,19,5,1], + [2,20,5,1], + [1, 5,3,0], + [2, 5,3,1], + ], ], - 'rows' => [ - [1,"john.doe@example.com","Interesting"], - [2,"john.doe@example.com","Fascinating"], - [3,"jane.doe@example.com","Boring"], - [4,"john.doe@example.com","Lonely"], - ], - ], - 'arsse_label_members' => [ - 'columns' => [ - 'label' => "int", - 'article' => "int", - 'subscription' => "int", - 'assigned' => "bool", - ], - 'rows' => [ - [1, 1,1,1], - [2, 1,1,1], - [1,19,5,1], - [2,20,5,1], - [1, 5,3,0], - [2, 5,3,1], - ], - ], - ]; - - public function setUpSeries() { + ]; $this->checkLabels = ['arsse_labels' => ["id","owner","name"]]; $this->checkMembers = ['arsse_label_members' => ["label","article","subscription","assigned"]]; $this->user = "john.doe@example.com"; } + protected function tearDownSeriesLabel() { + unset($this->data, $this->checkLabels, $this->checkMembers, $this->user); + } + public function testAddALabel() { $user = "john.doe@example.com"; $labelID = $this->nextID("arsse_labels"); diff --git a/tests/lib/Database/SeriesMeta.php b/tests/cases/Database/SeriesMeta.php similarity index 81% rename from tests/lib/Database/SeriesMeta.php rename to tests/cases/Database/SeriesMeta.php index 58ae20d..538700a 100644 --- a/tests/lib/Database/SeriesMeta.php +++ b/tests/cases/Database/SeriesMeta.php @@ -4,32 +4,35 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Test\Database; +namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Test\Database; use JKingWeb\Arsse\Arsse; trait SeriesMeta { - protected $dataBare = [ - 'arsse_meta' => [ - 'columns' => [ - 'key' => 'str', - 'value' => 'str', + protected function setUpSeriesMeta() { + $dataBare = [ + 'arsse_meta' => [ + 'columns' => [ + 'key' => 'str', + 'value' => 'str', + ], + 'rows' => [ + //['schema_version', "".\JKingWeb\Arsse\Database::SCHEMA_VERSION], + ['album',"A Farewell to Kings"], + ], ], - 'rows' => [ - //['schema_version', "".\JKingWeb\Arsse\Database::SCHEMA_VERSION], - ['album',"A Farewell to Kings"], - ], - ], - ]; - - public function setUpSeries() { + ]; // the schema_version key is a special case, and to avoid jumping through hoops for every test we deal with it now - $this->data = $this->dataBare; + $this->data = $dataBare; // as far as tests are concerned the schema version is part of the expectations primed into the database array_unshift($this->data['arsse_meta']['rows'], ['schema_version', "".Database::SCHEMA_VERSION]); // but it's already been inserted by the driver, so we prime without it - $this->primeDatabase($this->dataBare); + $this->primeDatabase($dataBare); + } + + protected function tearDownSeriesMeta() { + unset($this->data); } public function testAddANewValue() { diff --git a/tests/lib/Database/SeriesMiscellany.php b/tests/cases/Database/SeriesMiscellany.php similarity index 67% rename from tests/lib/Database/SeriesMiscellany.php rename to tests/cases/Database/SeriesMiscellany.php index e58c430..a865029 100644 --- a/tests/lib/Database/SeriesMiscellany.php +++ b/tests/cases/Database/SeriesMiscellany.php @@ -4,12 +4,21 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Test\Database; +namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Database; trait SeriesMiscellany { + protected function setUpSeriesMiscellany() { + static::setConf([ + 'dbDriver' => static::$dbInfo->driverClass, + ]); + } + + protected function tearDownSeriesMiscellany() { + } + public function testListDrivers() { $exp = [ 'JKingWeb\\Arsse\\Db\\SQLite3\\Driver' => Arsse::$lang->msg("Driver.Db.SQLite3.Name"), @@ -18,11 +27,13 @@ trait SeriesMiscellany { } public function testInitializeDatabase() { - $d = new Database(); + (static::$dbInfo->razeFunction)(static::$drv); + $d = new Database(true); $this->assertSame(Database::SCHEMA_VERSION, $d->driverSchemaVersion()); } public function testManuallyInitializeDatabase() { + (static::$dbInfo->razeFunction)(static::$drv); $d = new Database(false); $this->assertSame(0, $d->driverSchemaVersion()); $this->assertTrue($d->driverSchemaUpdate()); @@ -31,7 +42,6 @@ trait SeriesMiscellany { } public function testCheckCharacterSetAcceptability() { - $d = new Database(); - $this->assertInternalType("bool", $d->driverCharsetAcceptable()); + $this->assertInternalType("bool", Arsse::$db->driverCharsetAcceptable()); } } diff --git a/tests/lib/Database/SeriesSession.php b/tests/cases/Database/SeriesSession.php similarity index 96% rename from tests/lib/Database/SeriesSession.php rename to tests/cases/Database/SeriesSession.php index 26cf58a..c986742 100644 --- a/tests/lib/Database/SeriesSession.php +++ b/tests/cases/Database/SeriesSession.php @@ -4,16 +4,16 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Test\Database; +namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Misc\Date; use Phake; trait SeriesSession { - public function setUpSeries() { + protected function setUpSeriesSession() { // set up the configuration - Arsse::$conf->import([ + static::setConf([ 'userSessionTimeout' => "PT1H", 'userSessionLifetime' => "PT24H", ]); @@ -51,6 +51,10 @@ trait SeriesSession { ]; } + protected function tearDownSeriesSession() { + unset($this->data); + } + public function testResumeAValidSession() { $exp1 = [ 'id' => "80fa94c1a11f11e78667001e673b2560", diff --git a/tests/lib/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php similarity index 81% rename from tests/lib/Database/SeriesSubscription.php rename to tests/cases/Database/SeriesSubscription.php index a04fcf6..f2811f1 100644 --- a/tests/lib/Database/SeriesSubscription.php +++ b/tests/cases/Database/SeriesSubscription.php @@ -4,7 +4,7 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Test\Database; +namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Test\Database; @@ -12,112 +12,115 @@ use JKingWeb\Arsse\Feed\Exception as FeedException; use Phake; trait SeriesSubscription { - protected $data = [ - 'arsse_users' => [ - 'columns' => [ - 'id' => 'str', - 'password' => 'str', - 'name' => 'str', + public function setUpSeriesSubscription() { + $this->data = [ + 'arsse_users' => [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + 'name' => 'str', + ], + 'rows' => [ + ["jane.doe@example.com", "", "Jane Doe"], + ["john.doe@example.com", "", "John Doe"], + ], ], - 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + 'arsse_folders' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'parent' => "int", + 'name' => "str", + ], + 'rows' => [ + [1, "john.doe@example.com", null, "Technology"], + [2, "john.doe@example.com", 1, "Software"], + [3, "john.doe@example.com", 1, "Rocketry"], + [4, "jane.doe@example.com", null, "Politics"], + [5, "john.doe@example.com", null, "Politics"], + [6, "john.doe@example.com", 2, "Politics"], + ] ], - ], - 'arsse_folders' => [ - 'columns' => [ - 'id' => "int", - 'owner' => "str", - 'parent' => "int", - 'name' => "str", + 'arsse_feeds' => [ + 'columns' => [ + 'id' => "int", + 'url' => "str", + 'title' => "str", + 'username' => "str", + 'password' => "str", + 'next_fetch' => "datetime", + 'favicon' => "str", + ], + 'rows' => [] // filled in the series setup ], - 'rows' => [ - [1, "john.doe@example.com", null, "Technology"], - [2, "john.doe@example.com", 1, "Software"], - [3, "john.doe@example.com", 1, "Rocketry"], - [4, "jane.doe@example.com", null, "Politics"], - [5, "john.doe@example.com", null, "Politics"], - [6, "john.doe@example.com", 2, "Politics"], - ] - ], - 'arsse_feeds' => [ - 'columns' => [ - 'id' => "int", - 'url' => "str", - 'title' => "str", - 'username' => "str", - 'password' => "str", - 'next_fetch' => "datetime", - 'favicon' => "str", + 'arsse_subscriptions' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'feed' => "int", + 'title' => "str", + 'folder' => "int", + 'pinned' => "bool", + 'order_type' => "int", + ], + 'rows' => [ + [1,"john.doe@example.com",2,null,null,1,2], + [2,"jane.doe@example.com",2,null,null,0,0], + [3,"john.doe@example.com",3,"Ook",2,0,1], + ] ], - 'rows' => [] // filled in the series setup - ], - 'arsse_subscriptions' => [ - 'columns' => [ - 'id' => "int", - 'owner' => "str", - 'feed' => "int", - 'title' => "str", - 'folder' => "int", - 'pinned' => "bool", - 'order_type' => "int", + 'arsse_articles' => [ + 'columns' => [ + 'id' => "int", + 'feed' => "int", + 'url_title_hash' => "str", + 'url_content_hash' => "str", + 'title_content_hash' => "str", + ], + 'rows' => [ + [1,2,"","",""], + [2,2,"","",""], + [3,2,"","",""], + [4,2,"","",""], + [5,2,"","",""], + [6,3,"","",""], + [7,3,"","",""], + [8,3,"","",""], + ] ], - 'rows' => [ - [1,"john.doe@example.com",2,null,null,1,2], - [2,"jane.doe@example.com",2,null,null,0,0], - [3,"john.doe@example.com",3,"Ook",2,0,1], - ] - ], - 'arsse_articles' => [ - 'columns' => [ - 'id' => "int", - 'feed' => "int", - 'url_title_hash' => "str", - 'url_content_hash' => "str", - 'title_content_hash' => "str", + 'arsse_marks' => [ + 'columns' => [ + 'article' => "int", + 'subscription' => "int", + 'read' => "bool", + 'starred' => "bool", + ], + 'rows' => [ + [1,2,1,0], + [2,2,1,0], + [3,2,1,0], + [4,2,1,0], + [5,2,1,0], + [1,1,1,0], + [7,3,1,0], + [8,3,0,0], + ] ], - 'rows' => [ - [1,2,"","",""], - [2,2,"","",""], - [3,2,"","",""], - [4,2,"","",""], - [5,2,"","",""], - [6,3,"","",""], - [7,3,"","",""], - [8,3,"","",""], - ] - ], - 'arsse_marks' => [ - 'columns' => [ - 'article' => "int", - 'subscription' => "int", - 'read' => "bool", - 'starred' => "bool", - ], - 'rows' => [ - [1,2,1,0], - [2,2,1,0], - [3,2,1,0], - [4,2,1,0], - [5,2,1,0], - [1,1,1,0], - [7,3,1,0], - [8,3,0,0], - ] - ], - ]; - - public function setUpSeries() { + ]; $this->data['arsse_feeds']['rows'] = [ [1,"http://example.com/feed1", "Ook", "", "",strtotime("now"),''], [2,"http://example.com/feed2", "eek", "", "",strtotime("now - 1 hour"),'http://example.com/favicon.ico'], [3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour"),''], ]; // initialize a partial mock of the Database object to later manipulate the feedUpdate method - Arsse::$db = Phake::partialMock(Database::class, $this->drv); + Arsse::$db = Phake::partialMock(Database::class, static::$drv); $this->user = "john.doe@example.com"; } + protected function tearDownSeriesSubscription() { + unset($this->data, $this->user); + } + public function testAddASubscriptionToAnExistingFeed() { $url = "http://example.com/feed1"; $subID = $this->nextID("arsse_subscriptions"); diff --git a/tests/lib/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php similarity index 86% rename from tests/lib/Database/SeriesUser.php rename to tests/cases/Database/SeriesUser.php index 78d1f81..49c324b 100644 --- a/tests/lib/Database/SeriesUser.php +++ b/tests/cases/Database/SeriesUser.php @@ -4,28 +4,34 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Test\Database; +namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\User\Driver as UserDriver; use Phake; trait SeriesUser { - protected $data = [ - 'arsse_users' => [ - 'columns' => [ - 'id' => 'str', - 'password' => 'str', - 'name' => 'str', - 'rights' => 'int', + protected function setUpSeriesUser() { + $this->data = [ + 'arsse_users' => [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + 'name' => 'str', + 'rights' => 'int', + ], + 'rows' => [ + ["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW', "Hard Lip Herbert", 100], // password is hash of "secret" + ["jane.doe@example.com", "", "Jane Doe", 0], + ["john.doe@example.com", "", "John Doe", 0], + ], ], - 'rows' => [ - ["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW', "Hard Lip Herbert", 100], // password is hash of "secret" - ["jane.doe@example.com", "", "Jane Doe", 0], - ["john.doe@example.com", "", "John Doe", 0], - ], - ], - ]; + ]; + } + + protected function tearDownSeriesUser() { + unset($this->data); + } public function testCheckThatAUserExists() { $this->assertTrue(Arsse::$db->userExists("jane.doe@example.com")); diff --git a/tests/cases/Db/BaseDriver.php b/tests/cases/Db/BaseDriver.php new file mode 100644 index 0000000..5f30991 --- /dev/null +++ b/tests/cases/Db/BaseDriver.php @@ -0,0 +1,398 @@ + 0.5, + 'dbSQLite3Timeout' => 0, + //'dbSQLite3File' => "(temporary file)", + ]; + + public static function setUpBeforeClass() { + // establish a clean baseline + static::clearData(); + static::$dbInfo = new DatabaseInformation(static::$implementation); + static::setConf(static::$conf); + static::$interface = (static::$dbInfo->interfaceConstructor)(); + } + + public function setUp() { + self::clearData(); + self::setConf(static::$conf); + if (!static::$interface) { + $this->markTestSkipped(static::$implementation." database driver not available"); + } + // completely clear the database and ensure the schema version can easily be altered + (static::$dbInfo->razeFunction)(static::$interface, [ + "CREATE TABLE arsse_meta(key varchar(255) primary key not null, value text)", + "INSERT INTO arsse_meta(key,value) values('schema_version','0')", + ]); + // construct a fresh driver for each test + $this->drv = new static::$dbInfo->driverClass; + } + + public function tearDown() { + // deconstruct the driver + unset($this->drv); + self::clearData(); + } + + public static function tearDownAfterClass() { + if (static::$interface) { + // completely clear the database + (static::$dbInfo->razeFunction)(static::$interface); + } + static::$interface = null; + static::$dbInfo = null; + self::clearData(); + } + + protected function exec($q): bool { + // PDO implementation + $q = (!is_array($q)) ? [$q] : $q; + foreach ($q as $query) { + static::$interface->exec((string) $query); + } + return true; + } + + protected function query(string $q) { + // PDO implementation + return static::$interface->query($q)->fetchColumn(); + } + + # TESTS + + public function testFetchDriverName() { + $class = get_class($this->drv); + $this->assertTrue(strlen($class::driverName()) > 0); + } + + public function testFetchSchemaId() { + $class = get_class($this->drv); + $this->assertTrue(strlen($class::schemaID()) > 0); + } + + public function testCheckCharacterSetAcceptability() { + $this->assertTrue($this->drv->charsetAcceptable()); + } + + public function testTranslateAToken() { + $this->assertRegExp("/^[a-z][a-z0-9]*$/i", $this->drv->sqlToken("greatest")); + $this->assertSame("distinct", $this->drv->sqlToken("distinct")); + } + + public function testExecAValidStatement() { + $this->assertTrue($this->drv->exec($this->create)); + } + + 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("$this->create; INSERT INTO arsse_test(id) values(2112)")); + $this->assertEquals(2112, $this->query("SELECT id from arsse_test")); + } + + public function testExecTimeout() { + $this->exec($this->create); + $this->exec($this->lock); + $this->assertException("general", "Db", "ExceptionTimeout"); + $lock = is_array($this->lock) ? implode("; ", $this->lock) : $this->lock; + $this->drv->exec($lock); + } + + public function testExecConstraintViolation() { + $this->drv->exec("CREATE TABLE arsse_test(id varchar(255) not null)"); + $this->assertException("constraintViolation", "Db", "ExceptionInput"); + $this->drv->exec("INSERT INTO arsse_test default values"); + } + + public function testExecTypeViolation() { + $this->drv->exec($this->create); + $this->assertException("typeViolation", "Db", "ExceptionInput"); + $this->drv->exec("INSERT INTO arsse_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->exec($this->create); + $this->exec($this->lock); + $this->assertException("general", "Db", "ExceptionTimeout"); + $lock = is_array($this->lock) ? implode("; ", $this->lock) : $this->lock; + $this->drv->exec($lock); + } + + public function testQueryConstraintViolation() { + $this->drv->exec("CREATE TABLE arsse_test(id integer not null)"); + $this->assertException("constraintViolation", "Db", "ExceptionInput"); + $this->drv->query("INSERT INTO arsse_test default values"); + } + + public function testQueryTypeViolation() { + $this->drv->exec($this->create); + $this->assertException("typeViolation", "Db", "ExceptionInput"); + $this->drv->query("INSERT INTO arsse_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")->run(); + } + + 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 arsse_test"; + $insert = "INSERT INTO arsse_test default values"; + $this->drv->exec($this->create); + $tr = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $this->drv->query($insert); + $this->assertEquals(2, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + } + + public function testCommitATransaction() { + $select = "SELECT count(*) FROM arsse_test"; + $insert = "INSERT INTO arsse_test default values"; + $this->drv->exec($this->create); + $tr = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $tr->commit(); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(1, $this->query($select)); + } + + public function testRollbackATransaction() { + $select = "SELECT count(*) FROM arsse_test"; + $insert = "INSERT INTO arsse_test default values"; + $this->drv->exec($this->create); + $tr = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $tr->rollback(); + $this->assertEquals(0, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + } + + public function testBeginChainedTransactions() { + $select = "SELECT count(*) FROM arsse_test"; + $insert = "INSERT INTO arsse_test default values"; + $this->drv->exec($this->create); + $tr1 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $tr2 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(2, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + } + + public function testCommitChainedTransactions() { + $select = "SELECT count(*) FROM arsse_test"; + $insert = "INSERT INTO arsse_test default values"; + $this->drv->exec($this->create); + $tr1 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $tr2 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(2, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $tr2->commit(); + $this->assertEquals(0, $this->query($select)); + $tr1->commit(); + $this->assertEquals(2, $this->query($select)); + } + + public function testCommitChainedTransactionsOutOfOrder() { + $select = "SELECT count(*) FROM arsse_test"; + $insert = "INSERT INTO arsse_test default values"; + $this->drv->exec($this->create); + $tr1 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $tr2 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(2, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $tr1->commit(); + $this->assertEquals(2, $this->query($select)); + $tr2->commit(); + } + + public function testRollbackChainedTransactions() { + $select = "SELECT count(*) FROM arsse_test"; + $insert = "INSERT INTO arsse_test default values"; + $this->drv->exec($this->create); + $tr1 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $tr2 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(2, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $tr2->rollback(); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $tr1->rollback(); + $this->assertEquals(0, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + } + + public function testRollbackChainedTransactionsOutOfOrder() { + $select = "SELECT count(*) FROM arsse_test"; + $insert = "INSERT INTO arsse_test default values"; + $this->drv->exec($this->create); + $tr1 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $tr2 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(2, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $tr1->rollback(); + $this->assertEquals(0, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $tr2->rollback(); + $this->assertEquals(0, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + } + + public function testPartiallyRollbackChainedTransactions() { + $select = "SELECT count(*) FROM arsse_test"; + $insert = "INSERT INTO arsse_test default values"; + $this->drv->exec($this->create); + $tr1 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $tr2 = $this->drv->begin(); + $this->drv->query($insert); + $this->assertEquals(2, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $tr2->rollback(); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(0, $this->query($select)); + $tr1->commit(); + $this->assertEquals(1, $this->drv->query($select)->getValue()); + $this->assertEquals(1, $this->query($select)); + } + + public function testFetchSchemaVersion() { + $this->assertSame(0, $this->drv->schemaVersion()); + $this->drv->exec(str_replace("#", "1", $this->setVersion)); + $this->assertSame(1, $this->drv->schemaVersion()); + $this->drv->exec(str_replace("#", "2", $this->setVersion)); + $this->assertSame(2, $this->drv->schemaVersion()); + // SQLite is unaffected by the removal of the metadata table; other backends are + // in neither case should a query for the schema version produce an error, however + $this->exec("DROP TABLE IF EXISTS arsse_meta"); + $exp = (static::$dbInfo->backend == "SQLite 3") ? 2 : 0; + $this->assertSame($exp, $this->drv->schemaVersion()); + } + + public function testLockTheDatabase() { + // PostgreSQL doesn't actually lock the whole database, only the metadata table + // normally the application will first query this table to ensure the schema version is correct, + // so the effect is usually the same + $this->drv->savepointCreate(true); + $this->assertException(); + $this->exec($this->lock); + } + + public function testUnlockTheDatabase() { + $this->drv->savepointCreate(true); + $this->drv->savepointRelease(); + $this->drv->savepointCreate(true); + $this->drv->savepointUndo(); + $this->assertTrue($this->exec(str_replace("#", "3", $this->setVersion))); + } +} diff --git a/tests/cases/Db/BaseResult.php b/tests/cases/Db/BaseResult.php new file mode 100644 index 0000000..bb1725b --- /dev/null +++ b/tests/cases/Db/BaseResult.php @@ -0,0 +1,137 @@ +interfaceConstructor)(); + } + + public function setUp() { + self::clearData(); + self::setConf(); + if (!static::$interface) { + $this->markTestSkipped(static::$implementation." database driver not available"); + } + // completely clear the database + (static::$dbInfo->razeFunction)(static::$interface); + $this->resultClass = static::$dbInfo->resultClass; + $this->stringOutput = static::$dbInfo->stringOutput; + } + + public function tearDown() { + self::clearData(); + } + + public static function tearDownAfterClass() { + if (static::$interface) { + // completely clear the database + (static::$dbInfo->razeFunction)(static::$interface); + } + static::$interface = null; + static::$dbInfo = null; + self::clearData(); + } + + public function testConstructResult() { + $this->assertInstanceOf(Result::class, new $this->resultClass(...$this->makeResult("SELECT 1"))); + } + + public function testGetChangeCountAndLastInsertId() { + $this->makeResult(static::$createMeta); + $r = new $this->resultClass(...$this->makeResult("INSERT INTO arsse_meta(key,value) values('test', 1)")); + $this->assertSame(1, $r->changes()); + $this->assertSame(0, $r->lastId()); + } + + public function testGetChangeCountAndLastInsertIdBis() { + $this->makeResult(static::$createTest); + $r = new $this->resultClass(...$this->makeResult("INSERT INTO arsse_test default values")); + $this->assertSame(1, $r->changes()); + $this->assertSame(1, $r->lastId()); + $r = new $this->resultClass(...$this->makeResult("INSERT INTO arsse_test default values")); + $this->assertSame(1, $r->changes()); + $this->assertSame(2, $r->lastId()); + } + + public function testIterateOverResults() { + $exp = [0 => 1, 1 => 2, 2 => 3]; + $exp = $this->stringOutput ? $this->stringify($exp) : $exp; + foreach (new $this->resultClass(...$this->makeResult("SELECT 1 as col union select 2 as col union select 3 as col")) as $index => $row) { + $rows[$index] = $row['col']; + } + $this->assertSame($exp, $rows); + } + + public function testIterateOverResultsTwice() { + $exp = [0 => 1, 1 => 2, 2 => 3]; + $exp = $this->stringOutput ? $this->stringify($exp) : $exp; + $result = new $this->resultClass(...$this->makeResult("SELECT 1 as col union select 2 as col union select 3 as col")); + foreach ($result as $index => $row) { + $rows[$index] = $row['col']; + } + $this->assertSame($exp, $rows); + $this->assertException("resultReused", "Db"); + foreach ($result as $row) { + $rows[] = $row['col']; + } + } + + public function testGetSingleValues() { + $exp = [1867, 1970, 2112]; + $exp = $this->stringOutput ? $this->stringify($exp) : $exp; + $test = new $this->resultClass(...$this->makeResult("SELECT 1867 as year union all select 1970 as year union all select 2112 as year")); + $this->assertSame($exp[0], $test->getValue()); + $this->assertSame($exp[1], $test->getValue()); + $this->assertSame($exp[2], $test->getValue()); + $this->assertSame(null, $test->getValue()); + } + + public function testGetFirstValuesOnly() { + $exp = [1867, 1970, 2112]; + $exp = $this->stringOutput ? $this->stringify($exp) : $exp; + $test = new $this->resultClass(...$this->makeResult("SELECT 1867 as year, 19 as century union all select 1970 as year, 20 as century union all select 2112 as year, 22 as century")); + $this->assertSame($exp[0], $test->getValue()); + $this->assertSame($exp[1], $test->getValue()); + $this->assertSame($exp[2], $test->getValue()); + $this->assertSame(null, $test->getValue()); + } + + public function testGetRows() { + $exp = [ + ['album' => '2112', 'track' => '2112'], + ['album' => 'Clockwork Angels', 'track' => 'The Wreckers'], + ]; + $test = new $this->resultClass(...$this->makeResult("SELECT '2112' as album, '2112' as track union select 'Clockwork Angels' as album, 'The Wreckers' as track")); + $this->assertSame($exp[0], $test->getRow()); + $this->assertSame($exp[1], $test->getRow()); + $this->assertSame(null, $test->getRow()); + } + + public function testGetAllRows() { + $exp = [ + ['album' => '2112', 'track' => '2112'], + ['album' => 'Clockwork Angels', 'track' => 'The Wreckers'], + ]; + $test = new $this->resultClass(...$this->makeResult("SELECT '2112' as album, '2112' as track union select 'Clockwork Angels' as album, 'The Wreckers' as track")); + $this->assertEquals($exp, $test->getAll()); + } +} diff --git a/tests/cases/Db/BaseStatement.php b/tests/cases/Db/BaseStatement.php new file mode 100644 index 0000000..c3c3704 --- /dev/null +++ b/tests/cases/Db/BaseStatement.php @@ -0,0 +1,333 @@ +interfaceConstructor)(); + } + + public function setUp() { + self::clearData(); + self::setConf(); + if (!static::$interface) { + $this->markTestSkipped(static::$implementation." database driver not available"); + } + // completely clear the database + (static::$dbInfo->razeFunction)(static::$interface); + $this->statementClass = static::$dbInfo->statementClass; + $this->stringOutput = static::$dbInfo->stringOutput; + } + + public function tearDown() { + self::clearData(); + } + + public static function tearDownAfterClass() { + if (static::$interface) { + // completely clear the database + (static::$dbInfo->razeFunction)(static::$interface); + } + static::$interface = null; + static::$dbInfo = null; + self::clearData(); + } + + public function testConstructStatement() { + $this->assertInstanceOf(Statement::class, new $this->statementClass(...$this->makeStatement("SELECT ? as value"))); + } + + /** @dataProvider provideBindings */ + public function testBindATypedValue($value, string $type, string $exp) { + if ($exp=="null") { + $query = "SELECT (? is null) as pass"; + } else { + $query = "SELECT ($exp = ?) as pass"; + } + $typeStr = "'".str_replace("'", "''", $type)."'"; + $s = new $this->statementClass(...$this->makeStatement($query)); + $s->retype(...[$type]); + $act = $s->run(...[$value])->getValue(); + $this->assertTrue((bool) $act); + } + + /** @dataProvider provideBinaryBindings */ + public function testHandleBinaryData($value, string $type, string $exp) { + if (in_array(static::$implementation, ["PostgreSQL", "PDO PostgreSQL"])) { + $this->markTestSkipped("Correct handling of binary data with PostgreSQL is currently unknown"); + } + if ($exp=="null") { + $query = "SELECT (? is null) as pass"; + } else { + $query = "SELECT ($exp = ?) as pass"; + } + $typeStr = "'".str_replace("'", "''", $type)."'"; + $s = new $this->statementClass(...$this->makeStatement($query)); + $s->retype(...[$type]); + $act = $s->run(...[$value])->getValue(); + $this->assertTrue((bool) $act); + } + + public function testBindMissingValue() { + $s = new $this->statementClass(...$this->makeStatement("SELECT ? as value", ["int"])); + $val = $s->runArray()->getRow()['value']; + $this->assertSame(null, $val); + } + + public function testBindMultipleValues() { + $exp = [ + 'one' => 1, + 'two' => 2, + ]; + $exp = $this->stringOutput ? $this->stringify($exp) : $exp; + $s = new $this->statementClass(...$this->makeStatement("SELECT ? as one, ? as two", ["int", "int"])); + $val = $s->runArray([1,2])->getRow(); + $this->assertSame($exp, $val); + } + + public function testBindRecursively() { + $exp = [ + 'one' => 1, + 'two' => 2, + 'three' => 3, + 'four' => 4, + ]; + $exp = $this->stringOutput ? $this->stringify($exp) : $exp; + $s = new $this->statementClass(...$this->makeStatement("SELECT ? as one, ? as two, ? as three, ? as four", ["int", ["int", "int"], "int"])); + $val = $s->runArray([1, [2, 3], 4])->getRow(); + $this->assertSame($exp, $val); + } + + public function testBindWithoutType() { + $this->assertException("paramTypeMissing", "Db"); + $s = new $this->statementClass(...$this->makeStatement("SELECT ? as value", [])); + $s->runArray([1]); + } + + public function testViolateConstraint() { + (new $this->statementClass(...$this->makeStatement("CREATE TABLE if not exists arsse_meta(key varchar(255) primary key not null, value text)")))->run(); + $s = new $this->statementClass(...$this->makeStatement("INSERT INTO arsse_meta(key) values(?)", ["str"])); + $this->assertException("constraintViolation", "Db", "ExceptionInput"); + $s->runArray([null]); + } + + public function testMismatchTypes() { + (new $this->statementClass(...$this->makeStatement("CREATE TABLE if not exists arsse_feeds(id integer primary key not null, url text not null)")))->run(); + $s = new $this->statementClass(...$this->makeStatement("INSERT INTO arsse_feeds(id,url) values(?,?)", ["str", "str"])); + $this->assertException("typeViolation", "Db", "ExceptionInput"); + $s->runArray(['ook', 'eek']); + } + + 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")); + $tests = [ + 'Null as integer' => [null, "integer", "null"], + 'Null as float' => [null, "float", "null"], + 'Null as string' => [null, "string", "null"], + 'Null as datetime' => [null, "datetime", "null"], + 'Null as boolean' => [null, "boolean", "null"], + 'Null as strict integer' => [null, "strict integer", "0"], + 'Null as strict float' => [null, "strict float", "0.0"], + 'Null as strict string' => [null, "strict string", "''"], + 'Null as strict datetime' => [null, "strict datetime", "'1970-01-01 00:00:00'"], + 'Null as strict boolean' => [null, "strict boolean", "0"], + 'True as integer' => [true, "integer", "1"], + 'True as float' => [true, "float", "1.0"], + 'True as string' => [true, "string", "'1'"], + 'True as datetime' => [true, "datetime", "null"], + 'True as boolean' => [true, "boolean", "1"], + 'True as strict integer' => [true, "strict integer", "1"], + 'True as strict float' => [true, "strict float", "1.0"], + 'True as strict string' => [true, "strict string", "'1'"], + 'True as strict datetime' => [true, "strict datetime", "'1970-01-01 00:00:00'"], + 'True as strict boolean' => [true, "strict boolean", "1"], + 'False as integer' => [false, "integer", "0"], + 'False as float' => [false, "float", "0.0"], + 'False as string' => [false, "string", "''"], + 'False as datetime' => [false, "datetime", "null"], + 'False as boolean' => [false, "boolean", "0"], + 'False as strict integer' => [false, "strict integer", "0"], + 'False as strict float' => [false, "strict float", "0.0"], + 'False as strict string' => [false, "strict string", "''"], + 'False as strict datetime' => [false, "strict datetime", "'1970-01-01 00:00:00'"], + 'False as strict boolean' => [false, "strict boolean", "0"], + 'Integer as integer' => [2112, "integer", "2112"], + 'Integer as float' => [2112, "float", "2112.0"], + 'Integer as string' => [2112, "string", "'2112'"], + 'Integer as datetime' => [2112, "datetime", "'1970-01-01 00:35:12'"], + 'Integer as boolean' => [2112, "boolean", "1"], + 'Integer as strict integer' => [2112, "strict integer", "2112"], + 'Integer as strict float' => [2112, "strict float", "2112.0"], + 'Integer as strict string' => [2112, "strict string", "'2112'"], + 'Integer as strict datetime' => [2112, "strict datetime", "'1970-01-01 00:35:12'"], + 'Integer as strict boolean' => [2112, "strict boolean", "1"], + 'Integer zero as integer' => [0, "integer", "0"], + 'Integer zero as float' => [0, "float", "0.0"], + 'Integer zero as string' => [0, "string", "'0'"], + 'Integer zero as datetime' => [0, "datetime", "'1970-01-01 00:00:00'"], + 'Integer zero as boolean' => [0, "boolean", "0"], + 'Integer zero as strict integer' => [0, "strict integer", "0"], + 'Integer zero as strict float' => [0, "strict float", "0.0"], + 'Integer zero as strict string' => [0, "strict string", "'0'"], + 'Integer zero as strict datetime' => [0, "strict datetime", "'1970-01-01 00:00:00'"], + 'Integer zero as strict boolean' => [0, "strict boolean", "0"], + 'Float as integer' => [2112.5, "integer", "2112"], + 'Float as float' => [2112.5, "float", "2112.5"], + 'Float as string' => [2112.5, "string", "'2112.5'"], + 'Float as datetime' => [2112.5, "datetime", "'1970-01-01 00:35:12'"], + 'Float as boolean' => [2112.5, "boolean", "1"], + 'Float as strict integer' => [2112.5, "strict integer", "2112"], + 'Float as strict float' => [2112.5, "strict float", "2112.5"], + 'Float as strict string' => [2112.5, "strict string", "'2112.5'"], + 'Float as strict datetime' => [2112.5, "strict datetime", "'1970-01-01 00:35:12'"], + 'Float as strict boolean' => [2112.5, "strict boolean", "1"], + 'Float zero as integer' => [0.0, "integer", "0"], + 'Float zero as float' => [0.0, "float", "0.0"], + 'Float zero as string' => [0.0, "string", "'0'"], + 'Float zero as datetime' => [0.0, "datetime", "'1970-01-01 00:00:00'"], + 'Float zero as boolean' => [0.0, "boolean", "0"], + 'Float zero as strict integer' => [0.0, "strict integer", "0"], + 'Float zero as strict float' => [0.0, "strict float", "0.0"], + 'Float zero as strict string' => [0.0, "strict string", "'0'"], + 'Float zero as strict datetime' => [0.0, "strict datetime", "'1970-01-01 00:00:00'"], + 'Float zero as strict boolean' => [0.0, "strict boolean", "0"], + 'ASCII string as integer' => ["Random string", "integer", "0"], + 'ASCII string as float' => ["Random string", "float", "0.0"], + 'ASCII string as string' => ["Random string", "string", "'Random string'"], + 'ASCII string as datetime' => ["Random string", "datetime", "null"], + 'ASCII string as boolean' => ["Random string", "boolean", "1"], + 'ASCII string as strict integer' => ["Random string", "strict integer", "0"], + 'ASCII string as strict float' => ["Random string", "strict float", "0.0"], + 'ASCII string as strict string' => ["Random string", "strict string", "'Random string'"], + 'ASCII string as strict datetime' => ["Random string", "strict datetime", "'1970-01-01 00:00:00'"], + 'ASCII string as strict boolean' => ["Random string", "strict boolean", "1"], + 'UTF-8 string as integer' => ["\u{e9}", "integer", "0"], + 'UTF-8 string as float' => ["\u{e9}", "float", "0.0"], + 'UTF-8 string as string' => ["\u{e9}", "string", "char(233)"], + 'UTF-8 string as datetime' => ["\u{e9}", "datetime", "null"], + 'UTF-8 string as boolean' => ["\u{e9}", "boolean", "1"], + 'UTF-8 string as strict integer' => ["\u{e9}", "strict integer", "0"], + 'UTF-8 string as strict float' => ["\u{e9}", "strict float", "0.0"], + 'UTF-8 string as strict string' => ["\u{e9}", "strict string", "char(233)"], + 'UTF-8 string as strict datetime' => ["\u{e9}", "strict datetime", "'1970-01-01 00:00:00'"], + 'UTF-8 string as strict boolean' => ["\u{e9}", "strict boolean", "1"], + 'ISO 8601 string as integer' => ["2017-01-09T13:11:17", "integer", "0"], + 'ISO 8601 string as float' => ["2017-01-09T13:11:17", "float", "0.0"], + 'ISO 8601 string as string' => ["2017-01-09T13:11:17", "string", "'2017-01-09T13:11:17'"], + 'ISO 8601 string as datetime' => ["2017-01-09T13:11:17", "datetime", "'2017-01-09 13:11:17'"], + 'ISO 8601 string as boolean' => ["2017-01-09T13:11:17", "boolean", "1"], + 'ISO 8601 string as strict integer' => ["2017-01-09T13:11:17", "strict integer", "0"], + 'ISO 8601 string as strict float' => ["2017-01-09T13:11:17", "strict float", "0.0"], + 'ISO 8601 string as strict string' => ["2017-01-09T13:11:17", "strict string", "'2017-01-09T13:11:17'"], + 'ISO 8601 string as strict datetime' => ["2017-01-09T13:11:17", "strict datetime", "'2017-01-09 13:11:17'"], + 'ISO 8601 string as strict boolean' => ["2017-01-09T13:11:17", "strict boolean", "1"], + 'Arbitrary date string as integer' => ["Today", "integer", "0"], + 'Arbitrary date string as float' => ["Today", "float", "0.0"], + 'Arbitrary date string as string' => ["Today", "string", "'Today'"], + 'Arbitrary date string as datetime' => ["Today", "datetime", "'".date_create("Today", new \DateTimezone("UTC"))->format("Y-m-d H:i:s")."'"], + 'Arbitrary date string as boolean' => ["Today", "boolean", "1"], + 'Arbitrary date string as strict integer' => ["Today", "strict integer", "0"], + 'Arbitrary date string as strict float' => ["Today", "strict float", "0.0"], + 'Arbitrary date string as strict string' => ["Today", "strict string", "'Today'"], + 'Arbitrary date string as strict datetime' => ["Today", "strict datetime", "'".date_create("Today", new \DateTimezone("UTC"))->format("Y-m-d H:i:s")."'"], + 'Arbitrary date string as strict boolean' => ["Today", "strict boolean", "1"], + 'DateTime as integer' => [$dateMutable, "integer", (string) $dateUTC->getTimestamp()], + 'DateTime as float' => [$dateMutable, "float", $dateUTC->getTimestamp().".0"], + 'DateTime as string' => [$dateMutable, "string", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + 'DateTime as datetime' => [$dateMutable, "datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + 'DateTime as boolean' => [$dateMutable, "boolean", "1"], + 'DateTime as strict integer' => [$dateMutable, "strict integer", (string) $dateUTC->getTimestamp()], + 'DateTime as strict float' => [$dateMutable, "strict float", $dateUTC->getTimestamp().".0"], + 'DateTime as strict string' => [$dateMutable, "strict string", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + 'DateTime as strict datetime' => [$dateMutable, "strict datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + 'DateTime as strict boolean' => [$dateMutable, "strict boolean", "1"], + 'DateTimeImmutable as integer' => [$dateImmutable, "integer", (string) $dateUTC->getTimestamp()], + 'DateTimeImmutable as float' => [$dateImmutable, "float", $dateUTC->getTimestamp().".0"], + 'DateTimeImmutable as string' => [$dateImmutable, "string", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + 'DateTimeImmutable as datetime' => [$dateImmutable, "datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + 'DateTimeImmutable as boolean' => [$dateImmutable, "boolean", "1"], + 'DateTimeImmutable as strict integer' => [$dateImmutable, "strict integer", (string) $dateUTC->getTimestamp()], + 'DateTimeImmutable as strict float' => [$dateImmutable, "strict float", $dateUTC->getTimestamp().".0"], + 'DateTimeImmutable as strict string' => [$dateImmutable, "strict string", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + 'DateTimeImmutable as strict datetime' => [$dateImmutable, "strict datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"], + 'DateTimeImmutable as strict boolean' => [$dateImmutable, "strict boolean", "1"], + ]; + foreach ($tests as $index => list($value, $type, $exp)) { + $t = preg_replace("<^strict >", "", $type); + $exp = ($exp=="null") ? $exp : $this->decorateTypeSyntax($exp, $t); + yield $index => [$value, $type, $exp]; + } + } + + public function provideBinaryBindings() { + $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")); + $tests = [ + 'Null as binary' => [null, "binary", "null"], + 'Null as strict binary' => [null, "strict binary", "x''"], + 'True as binary' => [true, "binary", "x'31'"], + 'True as strict binary' => [true, "strict binary", "x'31'"], + 'False as binary' => [false, "binary", "x''"], + 'False as strict binary' => [false, "strict binary", "x''"], + 'Integer as binary' => [2112, "binary", "x'32313132'"], + 'Integer as strict binary' => [2112, "strict binary", "x'32313132'"], + 'Integer zero as binary' => [0, "binary", "x'30'"], + 'Integer zero as strict binary' => [0, "strict binary", "x'30'"], + 'Float as binary' => [2112.5, "binary", "x'323131322e35'"], + 'Float as strict binary' => [2112.5, "strict binary", "x'323131322e35'"], + 'Float zero as binary' => [0.0, "binary", "x'30'"], + 'Float zero as strict binary' => [0.0, "strict binary", "x'30'"], + 'ASCII string as binary' => ["Random string", "binary", "x'52616e646f6d20737472696e67'"], + 'ASCII string as strict binary' => ["Random string", "strict binary", "x'52616e646f6d20737472696e67'"], + 'UTF-8 string as binary' => ["\u{e9}", "binary", "x'c3a9'"], + 'UTF-8 string as strict binary' => ["\u{e9}", "strict binary", "x'c3a9'"], + 'Binary string as integer' => [chr(233).chr(233), "integer", "0"], + 'Binary string as float' => [chr(233).chr(233), "float", "0.0"], + 'Binary string as string' => [chr(233).chr(233), "string", "'".chr(233).chr(233)."'"], + 'Binary string as binary' => [chr(233).chr(233), "binary", "x'e9e9'"], + 'Binary string as datetime' => [chr(233).chr(233), "datetime", "null"], + 'Binary string as boolean' => [chr(233).chr(233), "boolean", "1"], + 'Binary string as strict integer' => [chr(233).chr(233), "strict integer", "0"], + 'Binary string as strict float' => [chr(233).chr(233), "strict float", "0.0"], + 'Binary string as strict string' => [chr(233).chr(233), "strict string", "'".chr(233).chr(233)."'"], + 'Binary string as strict binary' => [chr(233).chr(233), "strict binary", "x'e9e9'"], + 'Binary string as strict datetime' => [chr(233).chr(233), "strict datetime", "'1970-01-01 00:00:00'"], + 'Binary string as strict boolean' => [chr(233).chr(233), "strict boolean", "1"], + 'ISO 8601 string as binary' => ["2017-01-09T13:11:17", "binary", "x'323031372d30312d30395431333a31313a3137'"], + 'ISO 8601 string as strict binary' => ["2017-01-09T13:11:17", "strict binary", "x'323031372d30312d30395431333a31313a3137'"], + 'Arbitrary date string as binary' => ["Today", "binary", "x'546f646179'"], + 'Arbitrary date string as strict binary' => ["Today", "strict binary", "x'546f646179'"], + 'DateTime as binary' => [$dateMutable, "binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"], + 'DateTime as strict binary' => [$dateMutable, "strict binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"], + 'DateTimeImmutable as binary' => [$dateImmutable, "binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"], + 'DateTimeImmutable as strict binary' => [$dateImmutable, "strict binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"], + ]; + foreach ($tests as $index => list($value, $type, $exp)) { + $t = preg_replace("<^strict >", "", $type); + $exp = ($exp=="null") ? $exp : $this->decorateTypeSyntax($exp, $t); + yield $index => [$value, $type, $exp]; + } + } +} diff --git a/tests/cases/Db/BaseUpdate.php b/tests/cases/Db/BaseUpdate.php new file mode 100644 index 0000000..28cde46 --- /dev/null +++ b/tests/cases/Db/BaseUpdate.php @@ -0,0 +1,136 @@ +interfaceConstructor)(); + } + + public function setUp() { + if (!static::$interface) { + $this->markTestSkipped(static::$implementation." database driver not available"); + } + self::clearData(); + self::setConf(); + // construct a fresh driver for each test + $this->drv = new static::$dbInfo->driverClass; + $schemaId = (get_class($this->drv))::schemaID(); + // set up a virtual filesystem for schema files + $this->vfs = vfsStream::setup("schemata", null, [$schemaId => []]); + $this->base = $this->vfs->url(); + $this->path = $this->base."/$schemaId/"; + // completely clear the database + (static::$dbInfo->razeFunction)(static::$interface); + } + + public function tearDown() { + // deconstruct the driver + unset($this->drv); + unset($this->path, $this->base, $this->vfs); + self::clearData(); + } + + public static function tearDownAfterClass() { + if (static::$interface) { + // completely clear the database + (static::$dbInfo->razeFunction)(static::$interface); + } + static::$interface = null; + static::$dbInfo = null; + self::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", static::$minimal1); + $this->drv->schemaUpdate(1, $this->base); + $this->assertEquals(1, $this->drv->schemaVersion()); + } + + public function testPerformPartialUpdate() { + file_put_contents($this->path."0.sql", static::$minimal1); + file_put_contents($this->path."1.sql", "UPDATE arsse_meta set value = '1' where key = 'schema_version'"); + $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", static::$minimal1); + file_put_contents($this->path."1.sql", static::$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 + Arsse::$conf->dbAutoUpdate = false; + $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/Db/PostgreSQL/TestCreation.php b/tests/cases/Db/PostgreSQL/TestCreation.php new file mode 100644 index 0000000..182d3a3 --- /dev/null +++ b/tests/cases/Db/PostgreSQL/TestCreation.php @@ -0,0 +1,73 @@ + */ +class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest { + public function setUp() { + if (!Driver::requirementsMet()) { + $this->markTestSkipped("PostgreSQL extension not loaded"); + } + } + + /** @dataProvider provideConnectionStrings */ + public function testGenerateConnectionString(bool $pdo, string $user, string $pass, string $db, string $host, int $port, string $service, string $exp) { + self::setConf(); + $timeout = (string) ceil(Arsse::$conf->dbTimeoutConnect ?? 0); + $postfix = "application_name='arsse' client_encoding='UTF8' connect_timeout='$timeout'"; + $act = Driver::makeConnectionString($pdo, $user, $pass, $db, $host, $port, $service); + if ($act==$postfix) { + $this->assertSame($exp, ""); + } else { + $test = substr($act, 0, strlen($act) - (strlen($postfix) + 1)); + $check = substr($act, strlen($test) + 1); + $this->assertSame($postfix, $check); + $this->assertSame($exp, $test); + } + } + + public function provideConnectionStrings() { + return [ + [false, "arsse", "secret", "arsse", "", 5432, "", "dbname='arsse' password='secret' user='arsse'"], + [false, "arsse", "p word", "arsse", "", 5432, "", "dbname='arsse' password='p word' user='arsse'"], + [false, "arsse", "p'word", "arsse", "", 5432, "", "dbname='arsse' password='p\\'word' user='arsse'"], + [false, "arsse user", "secret", "arsse db", "", 5432, "", "dbname='arsse db' password='secret' user='arsse user'"], + [false, "arsse", "secret", "", "", 5432, "", "password='secret' user='arsse'"], + [false, "arsse", "secret", "arsse", "localhost", 5432, "", "dbname='arsse' host='localhost' password='secret' user='arsse'"], + [false, "arsse", "secret", "arsse", "", 9999, "", "dbname='arsse' password='secret' port='9999' user='arsse'"], + [false, "arsse", "secret", "arsse", "localhost", 9999, "", "dbname='arsse' host='localhost' password='secret' port='9999' user='arsse'"], + [false, "arsse", "secret", "arsse", "/socket", 9999, "", "dbname='arsse' host='/socket' password='secret' user='arsse'"], + [false, "T'Pau of Vulcan", "", "", "", 5432, "", "user='T\\'Pau of Vulcan'"], + [false, "T'Pau of Vulcan", "superman", "datumbase", "somehost", 2112, "arsse", "service='arsse'"], + [true, "arsse", "secret", "arsse", "", 5432, "", "dbname='arsse'"], + [true, "arsse", "p word", "arsse", "", 5432, "", "dbname='arsse'"], + [true, "arsse", "p'word", "arsse", "", 5432, "", "dbname='arsse'"], + [true, "arsse user", "secret", "arsse db", "", 5432, "", "dbname='arsse db'"], + [true, "arsse", "secret", "", "", 5432, "", ""], + [true, "arsse", "secret", "arsse", "localhost", 5432, "", "dbname='arsse' host='localhost'"], + [true, "arsse", "secret", "arsse", "", 9999, "", "dbname='arsse' port='9999'"], + [true, "arsse", "secret", "arsse", "localhost", 9999, "", "dbname='arsse' host='localhost' port='9999'"], + [true, "arsse", "secret", "arsse", "/socket", 9999, "", "dbname='arsse' host='/socket'"], + [true, "T'Pau of Vulcan", "", "", "", 5432, "", ""], + [true, "T'Pau of Vulcan", "superman", "datumbase", "somehost", 2112, "arsse", "service='arsse'"], + ]; + } + + public function testFailToConnect() { + // we cannnot distinguish between different connection failure modes + self::setConf([ + 'dbPostgreSQLPass' => (string) rand(), + ]); + $this->assertException("connectionFailure", "Db"); + new Driver; + } +} diff --git a/tests/cases/Db/PostgreSQL/TestDatabase.php b/tests/cases/Db/PostgreSQL/TestDatabase.php new file mode 100644 index 0000000..efc19a6 --- /dev/null +++ b/tests/cases/Db/PostgreSQL/TestDatabase.php @@ -0,0 +1,43 @@ + + * @covers \JKingWeb\Arsse\Misc\Query + */ +class TestDatabase extends \JKingWeb\Arsse\TestCase\Database\Base { + protected static $implementation = "PostgreSQL"; + + protected function nextID(string $table): int { + return (int) static::$drv->query("SELECT coalesce(last_value, (select max(id) from $table)) + 1 from pg_sequences where sequencename = '{$table}_id_seq'")->getValue(); + } + + public function setUp() { + parent::setUp(); + $seqList = + "select + replace(substring(column_default, 10), right(column_default, 12), '') as seq, + table_name as table, + column_name as col + from information_schema.columns + where table_schema = current_schema() + and table_name like 'arsse_%' + and column_default like 'nextval(%' + "; + foreach (static::$drv->query($seqList) as $r) { + $num = (int) static::$drv->query("SELECT max({$r['col']}) from {$r['table']}")->getValue(); + if (!$num) { + continue; + } + $num++; + static::$drv->exec("ALTER SEQUENCE {$r['seq']} RESTART WITH $num"); + } + } +} diff --git a/tests/cases/Db/PostgreSQL/TestDriver.php b/tests/cases/Db/PostgreSQL/TestDriver.php new file mode 100644 index 0000000..0c1f152 --- /dev/null +++ b/tests/cases/Db/PostgreSQL/TestDriver.php @@ -0,0 +1,58 @@ + + * @covers \JKingWeb\Arsse\Db\PostgreSQL\Dispatch */ +class TestDriver extends \JKingWeb\Arsse\TestCase\Db\BaseDriver { + protected static $implementation = "PostgreSQL"; + protected $create = "CREATE TABLE arsse_test(id bigserial primary key)"; + protected $lock = ["BEGIN", "LOCK TABLE arsse_meta IN EXCLUSIVE MODE NOWAIT"]; + protected $setVersion = "UPDATE arsse_meta set value = '#' where key = 'schema_version'"; + + public function tearDown() { + try { + $this->drv->exec("ROLLBACK"); + } catch (\Throwable $e) { + } + parent::tearDown(); + } + + public static function tearDownAfterClass() { + if (static::$interface) { + (static::$dbInfo->razeFunction)(static::$interface); + @pg_close(static::$interface); + static::$interface = null; + } + parent::tearDownAfterClass(); + } + + protected function exec($q): bool { + $q = (!is_array($q)) ? [$q] : $q; + foreach ($q as $query) { + set_error_handler(function($code, $msg) { + throw new \Exception($msg); + }); + try { + pg_query(static::$interface, $query); + } finally { + restore_error_handler(); + } + } + return true; + } + + protected function query(string $q) { + if ($r = pg_query_params(static::$interface, $q, [])) { + return pg_fetch_result($r, 0, 0); + } else { + return; + } + } +} diff --git a/tests/cases/Db/PostgreSQL/TestResult.php b/tests/cases/Db/PostgreSQL/TestResult.php new file mode 100644 index 0000000..08fff06 --- /dev/null +++ b/tests/cases/Db/PostgreSQL/TestResult.php @@ -0,0 +1,33 @@ + + */ +class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult { + protected static $implementation = "PostgreSQL"; + protected static $createMeta = "CREATE TABLE arsse_meta(key text primary key not null, value text)"; + protected static $createTest = "CREATE TABLE arsse_test(id bigserial primary key)"; + + protected function makeResult(string $q): array { + $set = pg_query(static::$interface, $q); + return [static::$interface, $set]; + } + + public static function tearDownAfterClass() { + if (static::$interface) { + (static::$dbInfo->razeFunction)(static::$interface); + @pg_close(static::$interface); + static::$interface = null; + } + parent::tearDownAfterClass(); + } +} diff --git a/tests/cases/Db/PostgreSQL/TestStatement.php b/tests/cases/Db/PostgreSQL/TestStatement.php new file mode 100644 index 0000000..e0e9b02 --- /dev/null +++ b/tests/cases/Db/PostgreSQL/TestStatement.php @@ -0,0 +1,42 @@ + + * @covers \JKingWeb\Arsse\Db\PostgreSQL\Dispatch */ +class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement { + protected static $implementation = "PostgreSQL"; + + protected function makeStatement(string $q, array $types = []): array { + return [static::$interface, $q, $types]; + } + + protected function decorateTypeSyntax(string $value, string $type): string { + switch ($type) { + case "float": + return (substr($value, -2)==".0") ? "'".substr($value, 0, strlen($value) - 2)."'" : "'$value'"; + case "string": + if (preg_match("<^char\((\d+)\)$>", $value, $match)) { + return "U&'\\+".str_pad(dechex((int) $match[1]), 6, "0", \STR_PAD_LEFT)."'"; + } + return $value; + default: + return $value; + } + } + + public static function tearDownAfterClass() { + if (static::$interface) { + (static::$dbInfo->razeFunction)(static::$interface); + @pg_close(static::$interface); + static::$interface = null; + } + parent::tearDownAfterClass(); + } +} diff --git a/tests/cases/Db/PostgreSQL/TestUpdate.php b/tests/cases/Db/PostgreSQL/TestUpdate.php new file mode 100644 index 0000000..cbdcb0b --- /dev/null +++ b/tests/cases/Db/PostgreSQL/TestUpdate.php @@ -0,0 +1,16 @@ + */ +class TestUpdate extends \JKingWeb\Arsse\TestCase\Db\BaseUpdate { + protected static $implementation = "PostgreSQL"; + protected static $minimal1 = "CREATE TABLE arsse_meta(key text primary key, value text); INSERT INTO arsse_meta(key,value) values('schema_version','1');"; + protected static $minimal2 = "UPDATE arsse_meta set value = '2' where key = 'schema_version';"; +} diff --git a/tests/cases/Db/PostgreSQLPDO/TestCreation.php b/tests/cases/Db/PostgreSQLPDO/TestCreation.php new file mode 100644 index 0000000..83d95e8 --- /dev/null +++ b/tests/cases/Db/PostgreSQLPDO/TestCreation.php @@ -0,0 +1,73 @@ + */ +class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest { + public function setUp() { + if (!Driver::requirementsMet()) { + $this->markTestSkipped("PDO-PostgreSQL extension not loaded"); + } + } + + /** @dataProvider provideConnectionStrings */ + public function testGenerateConnectionString(bool $pdo, string $user, string $pass, string $db, string $host, int $port, string $service, string $exp) { + self::setConf(); + $timeout = (string) ceil(Arsse::$conf->dbTimeoutConnect ?? 0); + $postfix = "application_name='arsse' client_encoding='UTF8' connect_timeout='$timeout'"; + $act = Driver::makeConnectionString($pdo, $user, $pass, $db, $host, $port, $service); + if ($act==$postfix) { + $this->assertSame($exp, ""); + } else { + $test = substr($act, 0, strlen($act) - (strlen($postfix) + 1)); + $check = substr($act, strlen($test) + 1); + $this->assertSame($postfix, $check); + $this->assertSame($exp, $test); + } + } + + public function provideConnectionStrings() { + return [ + [false, "arsse", "secret", "arsse", "", 5432, "", "dbname='arsse' password='secret' user='arsse'"], + [false, "arsse", "p word", "arsse", "", 5432, "", "dbname='arsse' password='p word' user='arsse'"], + [false, "arsse", "p'word", "arsse", "", 5432, "", "dbname='arsse' password='p\\'word' user='arsse'"], + [false, "arsse user", "secret", "arsse db", "", 5432, "", "dbname='arsse db' password='secret' user='arsse user'"], + [false, "arsse", "secret", "", "", 5432, "", "password='secret' user='arsse'"], + [false, "arsse", "secret", "arsse", "localhost", 5432, "", "dbname='arsse' host='localhost' password='secret' user='arsse'"], + [false, "arsse", "secret", "arsse", "", 9999, "", "dbname='arsse' password='secret' port='9999' user='arsse'"], + [false, "arsse", "secret", "arsse", "localhost", 9999, "", "dbname='arsse' host='localhost' password='secret' port='9999' user='arsse'"], + [false, "arsse", "secret", "arsse", "/socket", 9999, "", "dbname='arsse' host='/socket' password='secret' user='arsse'"], + [false, "T'Pau of Vulcan", "", "", "", 5432, "", "user='T\\'Pau of Vulcan'"], + [false, "T'Pau of Vulcan", "superman", "datumbase", "somehost", 2112, "arsse", "service='arsse'"], + [true, "arsse", "secret", "arsse", "", 5432, "", "dbname='arsse'"], + [true, "arsse", "p word", "arsse", "", 5432, "", "dbname='arsse'"], + [true, "arsse", "p'word", "arsse", "", 5432, "", "dbname='arsse'"], + [true, "arsse user", "secret", "arsse db", "", 5432, "", "dbname='arsse db'"], + [true, "arsse", "secret", "", "", 5432, "", ""], + [true, "arsse", "secret", "arsse", "localhost", 5432, "", "dbname='arsse' host='localhost'"], + [true, "arsse", "secret", "arsse", "", 9999, "", "dbname='arsse' port='9999'"], + [true, "arsse", "secret", "arsse", "localhost", 9999, "", "dbname='arsse' host='localhost' port='9999'"], + [true, "arsse", "secret", "arsse", "/socket", 9999, "", "dbname='arsse' host='/socket'"], + [true, "T'Pau of Vulcan", "", "", "", 5432, "", ""], + [true, "T'Pau of Vulcan", "superman", "datumbase", "somehost", 2112, "arsse", "service='arsse'"], + ]; + } + + public function testFailToConnect() { + // PDO dies not distinguish between different connection failure modes + self::setConf([ + 'dbPostgreSQLPass' => (string) rand(), + ]); + $this->assertException("connectionFailure", "Db"); + new Driver; + } +} diff --git a/tests/cases/Db/PostgreSQLPDO/TestDatabase.php b/tests/cases/Db/PostgreSQLPDO/TestDatabase.php new file mode 100644 index 0000000..6ce5de7 --- /dev/null +++ b/tests/cases/Db/PostgreSQLPDO/TestDatabase.php @@ -0,0 +1,44 @@ + + * @covers \JKingWeb\Arsse\Misc\Query + */ +class TestDatabase extends \JKingWeb\Arsse\TestCase\Database\Base { + protected static $implementation = "PDO PostgreSQL"; + + protected function nextID(string $table): int { + return (int) static::$drv->query("SELECT coalesce(last_value, (select max(id) from $table)) + 1 from pg_sequences where sequencename = '{$table}_id_seq'")->getValue(); + } + + public function setUp() { + parent::setUp(); + $seqList = + "select + replace(substring(column_default, 10), right(column_default, 12), '') as seq, + table_name as table, + column_name as col + from information_schema.columns + where table_schema = current_schema() + and table_name like 'arsse_%' + and column_default like 'nextval(%' + "; + foreach (static::$drv->query($seqList) as $r) { + $num = (int) static::$drv->query("SELECT max({$r['col']}) from {$r['table']}")->getValue(); + if (!$num) { + continue; + } + $num++; + static::$drv->exec("ALTER SEQUENCE {$r['seq']} RESTART WITH $num"); + } + } +} diff --git a/tests/cases/Db/PostgreSQLPDO/TestDriver.php b/tests/cases/Db/PostgreSQLPDO/TestDriver.php new file mode 100644 index 0000000..fa043bc --- /dev/null +++ b/tests/cases/Db/PostgreSQLPDO/TestDriver.php @@ -0,0 +1,27 @@ + + * @covers \JKingWeb\Arsse\Db\PDODriver + * @covers \JKingWeb\Arsse\Db\PDOError */ +class TestDriver extends \JKingWeb\Arsse\TestCase\Db\BaseDriver { + protected static $implementation = "PDO PostgreSQL"; + protected $create = "CREATE TABLE arsse_test(id bigserial primary key)"; + protected $lock = ["BEGIN", "LOCK TABLE arsse_meta IN EXCLUSIVE MODE NOWAIT"]; + protected $setVersion = "UPDATE arsse_meta set value = '#' where key = 'schema_version'"; + + public function tearDown() { + try { + $this->drv->exec("ROLLBACK"); + } catch (\Throwable $e) { + } + parent::tearDown(); + } +} diff --git a/tests/cases/Db/PostgreSQLPDO/TestResult.php b/tests/cases/Db/PostgreSQLPDO/TestResult.php new file mode 100644 index 0000000..86b4714 --- /dev/null +++ b/tests/cases/Db/PostgreSQLPDO/TestResult.php @@ -0,0 +1,24 @@ + + */ +class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult { + protected static $implementation = "PDO PostgreSQL"; + protected static $createMeta = "CREATE TABLE arsse_meta(key text primary key not null, value text)"; + protected static $createTest = "CREATE TABLE arsse_test(id bigserial primary key)"; + + protected function makeResult(string $q): array { + $set = static::$interface->query($q); + return [static::$interface, $set]; + } +} diff --git a/tests/cases/Db/PostgreSQLPDO/TestStatement.php b/tests/cases/Db/PostgreSQLPDO/TestStatement.php new file mode 100644 index 0000000..900c2e8 --- /dev/null +++ b/tests/cases/Db/PostgreSQLPDO/TestStatement.php @@ -0,0 +1,33 @@ + + * @covers \JKingWeb\Arsse\Db\PDOError */ +class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement { + protected static $implementation = "PDO PostgreSQL"; + + protected function makeStatement(string $q, array $types = []): array { + return [static::$interface, $q, $types]; + } + + protected function decorateTypeSyntax(string $value, string $type): string { + switch ($type) { + case "float": + return (substr($value, -2)==".0") ? "'".substr($value, 0, strlen($value) - 2)."'" : "'$value'"; + case "string": + if (preg_match("<^char\((\d+)\)$>", $value, $match)) { + return "U&'\\+".str_pad(dechex((int) $match[1]), 6, "0", \STR_PAD_LEFT)."'"; + } + return $value; + default: + return $value; + } + } +} diff --git a/tests/cases/Db/PostgreSQLPDO/TestUpdate.php b/tests/cases/Db/PostgreSQLPDO/TestUpdate.php new file mode 100644 index 0000000..92de569 --- /dev/null +++ b/tests/cases/Db/PostgreSQLPDO/TestUpdate.php @@ -0,0 +1,17 @@ + + * @covers \JKingWeb\Arsse\Db\PDOError */ +class TestUpdate extends \JKingWeb\Arsse\TestCase\Db\BaseUpdate { + protected static $implementation = "PDO PostgreSQL"; + protected static $minimal1 = "CREATE TABLE arsse_meta(key text primary key, value text); INSERT INTO arsse_meta(key,value) values('schema_version','1');"; + protected static $minimal2 = "UPDATE arsse_meta set value = '2' where key = 'schema_version';"; +} diff --git a/tests/cases/Db/SQLite3/Database/TestArticle.php b/tests/cases/Db/SQLite3/Database/TestArticle.php deleted file mode 100644 index 9531d4d..0000000 --- a/tests/cases/Db/SQLite3/Database/TestArticle.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -class TestArticle extends \JKingWeb\Arsse\Test\AbstractTest { - use \JKingWeb\Arsse\Test\Database\Setup; - use \JKingWeb\Arsse\Test\Database\DriverSQLite3; - use \JKingWeb\Arsse\Test\Database\SeriesArticle; -} diff --git a/tests/cases/Db/SQLite3/Database/TestCleanup.php b/tests/cases/Db/SQLite3/Database/TestCleanup.php deleted file mode 100644 index 5374e1b..0000000 --- a/tests/cases/Db/SQLite3/Database/TestCleanup.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -class TestCleanup extends \JKingWeb\Arsse\Test\AbstractTest { - use \JKingWeb\Arsse\Test\Database\Setup; - use \JKingWeb\Arsse\Test\Database\DriverSQLite3; - use \JKingWeb\Arsse\Test\Database\SeriesCleanup; -} diff --git a/tests/cases/Db/SQLite3/Database/TestFeed.php b/tests/cases/Db/SQLite3/Database/TestFeed.php deleted file mode 100644 index e46a17f..0000000 --- a/tests/cases/Db/SQLite3/Database/TestFeed.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest { - use \JKingWeb\Arsse\Test\Database\Setup; - use \JKingWeb\Arsse\Test\Database\DriverSQLite3; - use \JKingWeb\Arsse\Test\Database\SeriesFeed; -} diff --git a/tests/cases/Db/SQLite3/Database/TestFolder.php b/tests/cases/Db/SQLite3/Database/TestFolder.php deleted file mode 100644 index bc88e9a..0000000 --- a/tests/cases/Db/SQLite3/Database/TestFolder.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -class TestFolder extends \JKingWeb\Arsse\Test\AbstractTest { - use \JKingWeb\Arsse\Test\Database\Setup; - use \JKingWeb\Arsse\Test\Database\DriverSQLite3; - use \JKingWeb\Arsse\Test\Database\SeriesFolder; -} diff --git a/tests/cases/Db/SQLite3/Database/TestLabel.php b/tests/cases/Db/SQLite3/Database/TestLabel.php deleted file mode 100644 index 7092320..0000000 --- a/tests/cases/Db/SQLite3/Database/TestLabel.php +++ /dev/null @@ -1,13 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -class TestLabel extends \JKingWeb\Arsse\Test\AbstractTest { - use \JKingWeb\Arsse\Test\Database\Setup; - use \JKingWeb\Arsse\Test\Database\DriverSQLite3; - use \JKingWeb\Arsse\Test\Database\SeriesLabel; -} diff --git a/tests/cases/Db/SQLite3/Database/TestMeta.php b/tests/cases/Db/SQLite3/Database/TestMeta.php deleted file mode 100644 index 0693d30..0000000 --- a/tests/cases/Db/SQLite3/Database/TestMeta.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -class TestMeta extends \JKingWeb\Arsse\Test\AbstractTest { - use \JKingWeb\Arsse\Test\Database\Setup; - use \JKingWeb\Arsse\Test\Database\DriverSQLite3; - use \JKingWeb\Arsse\Test\Database\SeriesMeta; -} diff --git a/tests/cases/Db/SQLite3/Database/TestMiscellany.php b/tests/cases/Db/SQLite3/Database/TestMiscellany.php deleted file mode 100644 index 7701428..0000000 --- a/tests/cases/Db/SQLite3/Database/TestMiscellany.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -class TestMiscellany extends \JKingWeb\Arsse\Test\AbstractTest { - use \JKingWeb\Arsse\Test\Database\Setup; - use \JKingWeb\Arsse\Test\Database\DriverSQLite3; - use \JKingWeb\Arsse\Test\Database\SeriesMiscellany; -} diff --git a/tests/cases/Db/SQLite3/Database/TestSession.php b/tests/cases/Db/SQLite3/Database/TestSession.php deleted file mode 100644 index f8344b5..0000000 --- a/tests/cases/Db/SQLite3/Database/TestSession.php +++ /dev/null @@ -1,13 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -class TestSession extends \JKingWeb\Arsse\Test\AbstractTest { - use \JKingWeb\Arsse\Test\Database\Setup; - use \JKingWeb\Arsse\Test\Database\DriverSQLite3; - use \JKingWeb\Arsse\Test\Database\SeriesSession; -} diff --git a/tests/cases/Db/SQLite3/Database/TestSubscription.php b/tests/cases/Db/SQLite3/Database/TestSubscription.php deleted file mode 100644 index c7c6c57..0000000 --- a/tests/cases/Db/SQLite3/Database/TestSubscription.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -class TestSubscription extends \JKingWeb\Arsse\Test\AbstractTest { - use \JKingWeb\Arsse\Test\Database\Setup; - use \JKingWeb\Arsse\Test\Database\DriverSQLite3; - use \JKingWeb\Arsse\Test\Database\SeriesSubscription; -} diff --git a/tests/cases/Db/SQLite3/Database/TestUser.php b/tests/cases/Db/SQLite3/Database/TestUser.php deleted file mode 100644 index 3659bf9..0000000 --- a/tests/cases/Db/SQLite3/Database/TestUser.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -class TestUser extends \JKingWeb\Arsse\Test\AbstractTest { - use \JKingWeb\Arsse\Test\Database\Setup; - use \JKingWeb\Arsse\Test\Database\DriverSQLite3; - use \JKingWeb\Arsse\Test\Database\SeriesUser; -} diff --git a/tests/cases/Db/SQLite3/TestCreation.php b/tests/cases/Db/SQLite3/TestCreation.php index f9ac55b..d85aecd 100644 --- a/tests/cases/Db/SQLite3/TestCreation.php +++ b/tests/cases/Db/SQLite3/TestCreation.php @@ -24,7 +24,7 @@ class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest { if (!Driver::requirementsMet()) { $this->markTestSkipped("SQLite extension not loaded"); } - $this->clearData(); + self::clearData(); // test files $this->files = [ // cannot create files @@ -107,11 +107,11 @@ class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest { chmod($path."Awal/arsse.db-wal", 0111); chmod($path."Ashm/arsse.db-shm", 0111); // set up configuration - $this->setConf(['dbSQLite3File' => ":memory:"]); + self::setConf(); } public function tearDown() { - $this->clearData(); + self::clearData(); } public function testFailToCreateDatabase() { diff --git a/tests/cases/Db/SQLite3/TestDatabase.php b/tests/cases/Db/SQLite3/TestDatabase.php new file mode 100644 index 0000000..c65027c --- /dev/null +++ b/tests/cases/Db/SQLite3/TestDatabase.php @@ -0,0 +1,20 @@ + + * @covers \JKingWeb\Arsse\Misc\Query + */ +class TestDatabase extends \JKingWeb\Arsse\TestCase\Database\Base { + protected static $implementation = "SQLite 3"; + + protected function nextID(string $table): int { + return static::$drv->query("SELECT (case when max(id) then max(id) else 0 end)+1 from $table")->getValue(); + } +} diff --git a/tests/cases/Db/SQLite3/TestDriver.php b/tests/cases/Db/SQLite3/TestDriver.php index 835ad09..334a784 100644 --- a/tests/cases/Db/SQLite3/TestDriver.php +++ b/tests/cases/Db/SQLite3/TestDriver.php @@ -6,338 +6,42 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Db\SQLite3; -use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Conf; -use JKingWeb\Arsse\Database; -use JKingWeb\Arsse\Db\SQLite3\Driver; -use JKingWeb\Arsse\Db\Result; -use JKingWeb\Arsse\Db\Statement; - /** * @covers \JKingWeb\Arsse\Db\SQLite3\Driver * @covers \JKingWeb\Arsse\Db\SQLite3\ExceptionBuilder */ -class TestDriver extends \JKingWeb\Arsse\Test\AbstractTest { - protected $data; - protected $drv; - protected $ch; - - public function setUp() { - if (!Driver::requirementsMet()) { - $this->markTestSkipped("SQLite extension not loaded"); - } - $this->clearData(); - $this->setConf([ - 'dbDriver' => Driver::class, - 'dbSQLite3Timeout' => 0, - 'dbSQLite3File' => tempnam(sys_get_temp_dir(), 'ook'), - ]); - $this->drv = new Driver(); - $this->ch = new \SQLite3(Arsse::$conf->dbSQLite3File); - $this->ch->enableExceptions(true); - } - - public function tearDown() { - unset($this->drv); - unset($this->ch); - if (isset(Arsse::$conf)) { - unlink(Arsse::$conf->dbSQLite3File); +class TestDriver extends \JKingWeb\Arsse\TestCase\Db\BaseDriver { + protected static $implementation = "SQLite 3"; + protected $create = "CREATE TABLE arsse_test(id integer primary key)"; + protected $lock = "BEGIN EXCLUSIVE TRANSACTION"; + protected $setVersion = "PRAGMA user_version=#"; + protected static $file; + + public static function setUpBeforeClass() { + // create a temporary database file rather than using a memory database + // some tests require one connection to block another, so a memory database is not suitable + static::$file = tempnam(sys_get_temp_dir(), 'ook'); + static::$conf['dbSQLite3File'] = static::$file; + parent::setUpBeforeclass(); + } + + public static function tearDownAfterClass() { + static::$interface->close(); + static::$interface = null; + parent::tearDownAfterClass(); + @unlink(static::$file); + static::$file = null; + } + + protected function exec($q): bool { + // SQLite's implementation coincidentally matches PDO's, but we reproduce it here for correctness' sake + $q = (!is_array($q)) ? [$q] : $q; + foreach ($q as $query) { + static::$interface->exec((string) $query); } - $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->querySingle("SELECT id from test")); - } - - 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->querySingle($select)); - $this->drv->query($insert); - $this->assertEquals(2, $this->drv->query($select)->getValue()); - $this->assertEquals(0, $this->ch->querySingle($select)); - } - - 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->querySingle($select)); - $tr->commit(); - $this->assertEquals(1, $this->drv->query($select)->getValue()); - $this->assertEquals(1, $this->ch->querySingle($select)); - } - - 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->querySingle($select)); - $tr->rollback(); - $this->assertEquals(0, $this->drv->query($select)->getValue()); - $this->assertEquals(0, $this->ch->querySingle($select)); - } - - 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->querySingle($select)); - $tr2 = $this->drv->begin(); - $this->drv->query($insert); - $this->assertEquals(2, $this->drv->query($select)->getValue()); - $this->assertEquals(0, $this->ch->querySingle($select)); - } - - 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->querySingle($select)); - $tr2 = $this->drv->begin(); - $this->drv->query($insert); - $this->assertEquals(2, $this->drv->query($select)->getValue()); - $this->assertEquals(0, $this->ch->querySingle($select)); - $tr2->commit(); - $this->assertEquals(0, $this->ch->querySingle($select)); - $tr1->commit(); - $this->assertEquals(2, $this->ch->querySingle($select)); - } - - 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->querySingle($select)); - $tr2 = $this->drv->begin(); - $this->drv->query($insert); - $this->assertEquals(2, $this->drv->query($select)->getValue()); - $this->assertEquals(0, $this->ch->querySingle($select)); - $tr1->commit(); - $this->assertEquals(2, $this->ch->querySingle($select)); - $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->querySingle($select)); - $tr2 = $this->drv->begin(); - $this->drv->query($insert); - $this->assertEquals(2, $this->drv->query($select)->getValue()); - $this->assertEquals(0, $this->ch->querySingle($select)); - $tr2->rollback(); - $this->assertEquals(1, $this->drv->query($select)->getValue()); - $this->assertEquals(0, $this->ch->querySingle($select)); - $tr1->rollback(); - $this->assertEquals(0, $this->drv->query($select)->getValue()); - $this->assertEquals(0, $this->ch->querySingle($select)); - } - - 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->querySingle($select)); - $tr2 = $this->drv->begin(); - $this->drv->query($insert); - $this->assertEquals(2, $this->drv->query($select)->getValue()); - $this->assertEquals(0, $this->ch->querySingle($select)); - $tr1->rollback(); - $this->assertEquals(0, $this->drv->query($select)->getValue()); - $this->assertEquals(0, $this->ch->querySingle($select)); - $tr2->rollback(); - $this->assertEquals(0, $this->drv->query($select)->getValue()); - $this->assertEquals(0, $this->ch->querySingle($select)); - } - - 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->querySingle($select)); - $tr2 = $this->drv->begin(); - $this->drv->query($insert); - $this->assertEquals(2, $this->drv->query($select)->getValue()); - $this->assertEquals(0, $this->ch->querySingle($select)); - $tr2->rollback(); - $this->assertEquals(1, $this->drv->query($select)->getValue()); - $this->assertEquals(0, $this->ch->querySingle($select)); - $tr1->commit(); - $this->assertEquals(1, $this->drv->query($select)->getValue()); - $this->assertEquals(1, $this->ch->querySingle($select)); - } - - 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->assertException(); - $this->ch->exec("CREATE TABLE test(id integer primary key)"); + return true; } - public function testUnlockTheDatabase() { - $this->drv->savepointCreate(true); - $this->drv->savepointRelease(); - $this->drv->savepointCreate(true); - $this->drv->savepointUndo(); - $this->assertSame(true, $this->ch->exec("CREATE TABLE test(id integer primary key)")); + protected function query(string $q) { + return static::$interface->querySingle($q); } } diff --git a/tests/cases/Db/SQLite3/TestResult.php b/tests/cases/Db/SQLite3/TestResult.php new file mode 100644 index 0000000..4f6780a --- /dev/null +++ b/tests/cases/Db/SQLite3/TestResult.php @@ -0,0 +1,31 @@ + + */ +class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult { + protected static $implementation = "SQLite 3"; + protected static $createMeta = "CREATE TABLE arsse_meta(key text primary key not null, value text) without rowid"; + protected static $createTest = "CREATE TABLE arsse_test(id integer primary key)"; + + public static function tearDownAfterClass() { + static::$interface->close(); + static::$interface = null; + parent::tearDownAfterClass(); + } + + protected function makeResult(string $q): array { + $set = static::$interface->query($q); + $rows = static::$interface->changes(); + $id = static::$interface->lastInsertRowID(); + return [$set, [$rows, $id]]; + } +} diff --git a/tests/cases/Db/SQLite3/TestStatement.php b/tests/cases/Db/SQLite3/TestStatement.php new file mode 100644 index 0000000..7471b41 --- /dev/null +++ b/tests/cases/Db/SQLite3/TestStatement.php @@ -0,0 +1,28 @@ + + * @covers \JKingWeb\Arsse\Db\SQLite3\ExceptionBuilder */ +class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement { + protected static $implementation = "SQLite 3"; + + public static function tearDownAfterClass() { + static::$interface->close(); + static::$interface = null; + parent::tearDownAfterClass(); + } + + protected function makeStatement(string $q, array $types = []): array { + return [static::$interface, static::$interface->prepare($q), $types]; + } + + protected function decorateTypeSyntax(string $value, string $type): string { + return $value; + } +} diff --git a/tests/cases/Db/SQLite3/TestUpdate.php b/tests/cases/Db/SQLite3/TestUpdate.php index 2ff02e8..5331085 100644 --- a/tests/cases/Db/SQLite3/TestUpdate.php +++ b/tests/cases/Db/SQLite3/TestUpdate.php @@ -6,118 +6,17 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Db\SQLite3; -use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Conf; -use JKingWeb\Arsse\Database; -use JKingWeb\Arsse\Db\Exception; -use JKingWeb\Arsse\Db\SQLite3\Driver; -use org\bovigo\vfs\vfsStream; - /** * @covers \JKingWeb\Arsse\Db\SQLite3\Driver * @covers \JKingWeb\Arsse\Db\SQLite3\ExceptionBuilder */ -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 (!Driver::requirementsMet()) { - $this->markTestSkipped("SQLite extension not loaded"); - } - $this->clearData(); - $this->vfs = vfsStream::setup("schemata", null, ['SQLite3' => []]); - $conf = $conf ?? new Conf; - $conf->dbDriver = Driver::class; - $conf->dbSQLite3File = ":memory:"; - Arsse::$conf = $conf; - $this->base = $this->vfs->url(); - $this->path = $this->base."/SQLite3/"; - $this->drv = new Driver(); - } - - 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); +class TestUpdate extends \JKingWeb\Arsse\TestCase\Db\BaseUpdate { + protected static $implementation = "SQLite 3"; + protected static $minimal1 = "create table arsse_meta(key text primary key not null, value text); pragma user_version=1"; + protected static $minimal2 = "pragma user_version=2"; + + public static function tearDownAfterClass() { + static::$interface->close(); + static::$interface = null; + parent::tearDownAfterClass(); } } diff --git a/tests/cases/Db/SQLite3PDO/Database/TestArticle.php b/tests/cases/Db/SQLite3PDO/Database/TestArticle.php deleted file mode 100644 index 30521b4..0000000 --- a/tests/cases/Db/SQLite3PDO/Database/TestArticle.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -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 deleted file mode 100644 index 708001d..0000000 --- a/tests/cases/Db/SQLite3PDO/Database/TestCleanup.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -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 deleted file mode 100644 index e662d8e..0000000 --- a/tests/cases/Db/SQLite3PDO/Database/TestFeed.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -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 deleted file mode 100644 index 777a011..0000000 --- a/tests/cases/Db/SQLite3PDO/Database/TestFolder.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -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 deleted file mode 100644 index b2fe158..0000000 --- a/tests/cases/Db/SQLite3PDO/Database/TestLabel.php +++ /dev/null @@ -1,13 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -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 deleted file mode 100644 index 9698131..0000000 --- a/tests/cases/Db/SQLite3PDO/Database/TestMeta.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -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 deleted file mode 100644 index 868e7fc..0000000 --- a/tests/cases/Db/SQLite3PDO/Database/TestMiscellany.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -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 deleted file mode 100644 index 88535b2..0000000 --- a/tests/cases/Db/SQLite3PDO/Database/TestSession.php +++ /dev/null @@ -1,13 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -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 deleted file mode 100644 index 83e7daf..0000000 --- a/tests/cases/Db/SQLite3PDO/Database/TestSubscription.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -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 deleted file mode 100644 index 18b0c05..0000000 --- a/tests/cases/Db/SQLite3PDO/Database/TestUser.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @covers \JKingWeb\Arsse\Misc\Query - */ -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 index 4f2e1a2..526400b 100644 --- a/tests/cases/Db/SQLite3PDO/TestCreation.php +++ b/tests/cases/Db/SQLite3PDO/TestCreation.php @@ -25,7 +25,7 @@ class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest { if (!Driver::requirementsMet()) { $this->markTestSkipped("PDO-SQLite extension not loaded"); } - $this->clearData(); + self::clearData(); // test files $this->files = [ // cannot create files @@ -108,11 +108,11 @@ class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest { chmod($path."Awal/arsse.db-wal", 0111); chmod($path."Ashm/arsse.db-shm", 0111); // set up configuration - $this->setConf(['dbSQLite3File' => ":memory:"]); + self::setConf(); } public function tearDown() { - $this->clearData(); + self::clearData(); } public function testFailToCreateDatabase() { diff --git a/tests/cases/Db/SQLite3PDO/TestDatabase.php b/tests/cases/Db/SQLite3PDO/TestDatabase.php new file mode 100644 index 0000000..d52732c --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/TestDatabase.php @@ -0,0 +1,19 @@ + + * @covers \JKingWeb\Arsse\Misc\Query + */ +class TestDatabase extends \JKingWeb\Arsse\TestCase\Database\Base { + protected static $implementation = "PDO SQLite 3"; + + protected function nextID(string $table): int { + return (int) static::$drv->query("SELECT (case when max(id) then max(id) else 0 end)+1 from $table")->getValue(); + } +} diff --git a/tests/cases/Db/SQLite3PDO/TestDriver.php b/tests/cases/Db/SQLite3PDO/TestDriver.php index 6d58f51..475d575 100644 --- a/tests/cases/Db/SQLite3PDO/TestDriver.php +++ b/tests/cases/Db/SQLite3PDO/TestDriver.php @@ -6,339 +6,28 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Db\SQLite3PDO; -use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Conf; -use JKingWeb\Arsse\Database; -use JKingWeb\Arsse\Db\SQLite3\PDODriver; -use JKingWeb\Arsse\Db\Result; -use JKingWeb\Arsse\Db\Statement; - /** * @covers \JKingWeb\Arsse\Db\SQLite3\PDODriver * @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(); - $this->setConf([ - 'dbDriver' => PDODriver::class, - 'dbSQLite3Timeout' => 0, - '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)")); +class TestDriver extends \JKingWeb\Arsse\TestCase\Db\BaseDriver { + protected static $implementation = "PDO SQLite 3"; + protected $create = "CREATE TABLE arsse_test(id integer primary key)"; + protected $lock = "BEGIN EXCLUSIVE TRANSACTION"; + protected $setVersion = "PRAGMA user_version=#"; + protected static $file; + + public static function setUpBeforeClass() { + // create a temporary database file rather than using a memory database + // some tests require one connection to block another, so a memory database is not suitable + static::$file = tempnam(sys_get_temp_dir(), 'ook'); + static::$conf['dbSQLite3File'] = static::$file; + parent::setUpBeforeclass(); + } + + public static function tearDownAfterClass() { + parent::tearDownAfterClass(); + @unlink(self::$file); + self::$file = null; } } diff --git a/tests/cases/Db/SQLite3PDO/TestResult.php b/tests/cases/Db/SQLite3PDO/TestResult.php new file mode 100644 index 0000000..4161c44 --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/TestResult.php @@ -0,0 +1,23 @@ + + */ +class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult { + protected static $implementation = "PDO SQLite 3"; + protected static $createMeta = "CREATE TABLE arsse_meta(key text primary key not null, value text) without rowid"; + protected static $createTest = "CREATE TABLE arsse_test(id integer primary key)"; + + protected function makeResult(string $q): array { + $set = static::$interface->query($q); + return [static::$interface, $set]; + } +} diff --git a/tests/cases/Db/SQLite3PDO/TestStatement.php b/tests/cases/Db/SQLite3PDO/TestStatement.php new file mode 100644 index 0000000..9e2e06c --- /dev/null +++ b/tests/cases/Db/SQLite3PDO/TestStatement.php @@ -0,0 +1,26 @@ + + * @covers \JKingWeb\Arsse\Db\PDOError */ +class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement { + protected static $implementation = "PDO SQLite 3"; + + protected function makeStatement(string $q, array $types = []): array { + return [static::$interface, static::$interface->prepare($q), $types]; + } + + protected function decorateTypeSyntax(string $value, string $type): string { + if ($type=="float") { + return (substr($value, -2)==".0") ? "'".substr($value, 0, strlen($value) - 2)."'" : "'$value'"; + } else { + return $value; + } + } +} diff --git a/tests/cases/Db/SQLite3PDO/TestUpdate.php b/tests/cases/Db/SQLite3PDO/TestUpdate.php index 9c8df84..4a23595 100644 --- a/tests/cases/Db/SQLite3PDO/TestUpdate.php +++ b/tests/cases/Db/SQLite3PDO/TestUpdate.php @@ -6,120 +6,11 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Db\SQLite3PDO; -use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Conf; -use JKingWeb\Arsse\Database; -use JKingWeb\Arsse\Db\Exception; -use JKingWeb\Arsse\Db\SQLite3\PDODriver; -use org\bovigo\vfs\vfsStream; - /** * @covers \JKingWeb\Arsse\Db\SQLite3\PDODriver * @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); - } +class TestUpdate extends \JKingWeb\Arsse\TestCase\Db\BaseUpdate { + protected static $implementation = "PDO SQLite 3"; + protected static $minimal1 = "create table arsse_meta(key text primary key not null, value text); pragma user_version=1"; + protected static $minimal2 = "pragma user_version=2"; } diff --git a/tests/cases/Db/TestResult.php b/tests/cases/Db/TestResult.php deleted file mode 100644 index 9a252b8..0000000 --- a/tests/cases/Db/TestResult.php +++ /dev/null @@ -1,155 +0,0 @@ - - * @covers \JKingWeb\Arsse\Db\SQLite3\Result - */ -class TestResult extends \JKingWeb\Arsse\Test\AbstractTest { - public function provideDrivers() { - $drvSqlite3 = (function() { - if (\JKingWeb\Arsse\Db\SQLite3\Driver::requirementsMet()) { - $d = new \SQLite3(":memory:"); - $d->enableExceptions(true); - return $d; - } - })(); - $drvPdo = (function() { - if (\JKingWeb\Arsse\Db\SQLite3\PDODriver::requirementsMet()) { - return new \PDO("sqlite::memory:", "", "", [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); - } - })(); - return [ - 'SQLite 3' => [isset($drvSqlite3), false, \JKingWeb\Arsse\Db\SQLite3\Result::class, function(string $query) use($drvSqlite3) { - $set = $drvSqlite3->query($query); - $rows = $drvSqlite3->changes(); - $id = $drvSqlite3->lastInsertRowID(); - return [$set, [$rows, $id]]; - }], - 'PDO' => [isset($drvPdo), true, \JKingWeb\Arsse\Db\PDOResult::class, function(string $query) use($drvPdo) { - $set = $drvPdo->query($query); - $rows = $set->rowCount(); - $id = $drvPdo->lastInsertID(); - return [$set, [$rows, $id]]; - }], - ]; - } - - /** @dataProvider provideDrivers */ - public function testConstructResult(bool $driverTestable, bool $stringCoersion, string $class, \Closure $func) { - if (!$driverTestable) { - $this->markTestSkipped(); - } - $this->assertInstanceOf(Result::class, new $class(...$func("SELECT 1"))); - } - - /** @dataProvider provideDrivers */ - public function testGetChangeCountAndLastInsertId(bool $driverTestable, bool $stringCoersion, string $class, \Closure $func) { - if (!$driverTestable) { - $this->markTestSkipped(); - } - $func("CREATE TABLE if not exists arsse_meta(key varchar(255) primary key not null, value text)"); - $out = $func("INSERT INTO arsse_meta(key,value) values('test', 1)"); - $rows = $out[1][0]; - $id = $out[1][1]; - $r = new $class(...$out); - $this->assertSame((int) $rows, $r->changes()); - $this->assertSame((int) $id, $r->lastId()); - } - - /** @dataProvider provideDrivers */ - public function testIterateOverResults(bool $driverTestable, bool $stringCoersion, string $class, \Closure $func) { - if (!$driverTestable) { - $this->markTestSkipped(); - } - $exp = [0 => 1, 1 => 2, 2 => 3]; - $exp = $stringCoersion ? $this->stringify($exp) : $exp; - foreach (new $class(...$func("SELECT 1 as col union select 2 as col union select 3 as col")) as $index => $row) { - $rows[$index] = $row['col']; - } - $this->assertSame($exp, $rows); - } - - /** @dataProvider provideDrivers */ - public function testIterateOverResultsTwice(bool $driverTestable, bool $stringCoersion, string $class, \Closure $func) { - if (!$driverTestable) { - $this->markTestSkipped(); - } - $exp = [0 => 1, 1 => 2, 2 => 3]; - $exp = $stringCoersion ? $this->stringify($exp) : $exp; - $result = new $class(...$func("SELECT 1 as col union select 2 as col union select 3 as col")); - foreach ($result as $index => $row) { - $rows[$index] = $row['col']; - } - $this->assertSame($exp, $rows); - $this->assertException("resultReused", "Db"); - foreach ($result as $row) { - $rows[] = $row['col']; - } - } - - /** @dataProvider provideDrivers */ - public function testGetSingleValues(bool $driverTestable, bool $stringCoersion, string $class, \Closure $func) { - if (!$driverTestable) { - $this->markTestSkipped(); - } - $exp = [1867, 1970, 2112]; - $exp = $stringCoersion ? $this->stringify($exp) : $exp; - $test = new $class(...$func("SELECT 1867 as year union select 1970 as year union select 2112 as year")); - $this->assertSame($exp[0], $test->getValue()); - $this->assertSame($exp[1], $test->getValue()); - $this->assertSame($exp[2], $test->getValue()); - $this->assertSame(null, $test->getValue()); - } - - /** @dataProvider provideDrivers */ - public function testGetFirstValuesOnly(bool $driverTestable, bool $stringCoersion, string $class, \Closure $func) { - if (!$driverTestable) { - $this->markTestSkipped(); - } - $exp = [1867, 1970, 2112]; - $exp = $stringCoersion ? $this->stringify($exp) : $exp; - $test = new $class(...$func("SELECT 1867 as year, 19 as century union select 1970 as year, 20 as century union select 2112 as year, 22 as century")); - $this->assertSame($exp[0], $test->getValue()); - $this->assertSame($exp[1], $test->getValue()); - $this->assertSame($exp[2], $test->getValue()); - $this->assertSame(null, $test->getValue()); - } - - /** @dataProvider provideDrivers */ - public function testGetRows(bool $driverTestable, bool $stringCoersion, string $class, \Closure $func) { - if (!$driverTestable) { - $this->markTestSkipped(); - } - $exp = [ - ['album' => '2112', 'track' => '2112'], - ['album' => 'Clockwork Angels', 'track' => 'The Wreckers'], - ]; - $test = new $class(...$func("SELECT '2112' as album, '2112' as track union select 'Clockwork Angels' as album, 'The Wreckers' as track")); - $this->assertSame($exp[0], $test->getRow()); - $this->assertSame($exp[1], $test->getRow()); - $this->assertSame(null, $test->getRow()); - } - - /** @dataProvider provideDrivers */ - public function testGetAllRows(bool $driverTestable, bool $stringCoersion, string $class, \Closure $func) { - if (!$driverTestable) { - $this->markTestSkipped(); - } - $exp = [ - ['album' => '2112', 'track' => '2112'], - ['album' => 'Clockwork Angels', 'track' => 'The Wreckers'], - ]; - $test = new $class(...$func("SELECT '2112' as album, '2112' as track union select 'Clockwork Angels' as album, 'The Wreckers' as track")); - $this->assertEquals($exp, $test->getAll()); - } -} diff --git a/tests/cases/Db/TestStatement.php b/tests/cases/Db/TestStatement.php deleted file mode 100644 index 94a4885..0000000 --- a/tests/cases/Db/TestStatement.php +++ /dev/null @@ -1,332 +0,0 @@ - - * @covers \JKingWeb\Arsse\Db\SQLite3\ExceptionBuilder - * @covers \JKingWeb\Arsse\Db\PDOStatement - * @covers \JKingWeb\Arsse\Db\PDOError */ -class TestStatement extends \JKingWeb\Arsse\Test\AbstractTest { - public function provideDrivers() { - $drvSqlite3 = (function() { - if (\JKingWeb\Arsse\Db\SQLite3\Driver::requirementsMet()) { - $d = new \SQLite3(":memory:"); - $d->enableExceptions(true); - return $d; - } - })(); - $drvPdo = (function() { - if (\JKingWeb\Arsse\Db\SQLite3\PDODriver::requirementsMet()) { - return new \PDO("sqlite::memory:", "", "", [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); - } - })(); - return [ - 'SQLite 3' => [isset($drvSqlite3), false, \JKingWeb\Arsse\Db\SQLite3\Statement::class, function(string $query, array $types = []) use($drvSqlite3) { - $s = $drvSqlite3->prepare($query); - return [$drvSqlite3, $s, $types]; - }], - 'PDO' => [isset($drvPdo), true, \JKingWeb\Arsse\Db\PDOStatement::class, function(string $query, array $types = []) use($drvPdo) { - $s = $drvPdo->prepare($query); - return [$drvPdo, $s, $types]; - }], - ]; - } - - /** @dataProvider provideDrivers */ - public function testConstructStatement(bool $driverTestable, bool $stringCoersion, string $class, \Closure $func) { - if (!$driverTestable) { - $this->markTestSkipped(); - } - $this->assertInstanceOf(Statement::class, new $class(...$func("SELECT ? as value"))); - } - - /** @dataProvider provideBindings */ - public function testBindATypedValue(bool $driverTestable, string $class, \Closure $func, $value, string $type, string $exp, string $expPDO = null) { - if (!$driverTestable) { - $this->markTestSkipped(); - } - $exp = ($class == PDOStatement::class) ? ($expPDO ?? $exp) : $exp; - $typeStr = "'".str_replace("'", "''", $type)."'"; - $s = new $class(...$func( - "SELECT ( - (CASE WHEN substr($typeStr, 0, 7) <> 'strict ' then null else 1 end) is null - and ? is null - ) or ( - $exp = ? - ) as pass" - )); - $s->retype(...[$type, $type]); - $act = (bool) $s->run(...[$value, $value])->getRow()['pass']; - $this->assertTrue($act); - } - - /** @dataProvider provideDrivers */ - public function testBindMissingValue(bool $driverTestable, bool $stringCoersion, string $class, \Closure $func) { - if (!$driverTestable) { - $this->markTestSkipped(); - } - $s = new $class(...$func("SELECT ? as value")); - $val = $s->runArray()->getRow()['value']; - $this->assertSame(null, $val); - } - - /** @dataProvider provideDrivers */ - public function testBindMultipleValues(bool $driverTestable, bool $stringCoersion, string $class, \Closure $func) { - if (!$driverTestable) { - $this->markTestSkipped(); - } - $exp = [ - 'one' => 1, - 'two' => 2, - ]; - $exp = $stringCoersion ? $this->stringify($exp) : $exp; - $s = new $class(...$func("SELECT ? as one, ? as two", ["int", "int"])); - $val = $s->runArray([1,2])->getRow(); - $this->assertSame($exp, $val); - } - - /** @dataProvider provideDrivers */ - public function testBindRecursively(bool $driverTestable, bool $stringCoersion, string $class, \Closure $func) { - if (!$driverTestable) { - $this->markTestSkipped(); - } - $exp = [ - 'one' => 1, - 'two' => 2, - 'three' => 3, - 'four' => 4, - ]; - $exp = $stringCoersion ? $this->stringify($exp) : $exp; - $s = new $class(...$func("SELECT ? as one, ? as two, ? as three, ? as four", ["int", ["int", "int"], "int"])); - $val = $s->runArray([1, [2, 3], 4])->getRow(); - $this->assertSame($exp, $val); - } - - /** @dataProvider provideDrivers */ - public function testBindWithoutType(bool $driverTestable, bool $stringCoersion, string $class, \Closure $func) { - if (!$driverTestable) { - $this->markTestSkipped(); - } - $this->assertException("paramTypeMissing", "Db"); - $s = new $class(...$func("SELECT ? as value", [])); - $s->runArray([1]); - } - - /** @dataProvider provideDrivers */ - public function testViolateConstraint(bool $driverTestable, bool $stringCoersion, string $class, \Closure $func) { - if (!$driverTestable) { - $this->markTestSkipped(); - } - (new $class(...$func("CREATE TABLE if not exists arsse_meta(key varchar(255) primary key not null, value text)")))->run(); - $s = new $class(...$func("INSERT INTO arsse_meta(key) values(?)", ["str"])); - $this->assertException("constraintViolation", "Db", "ExceptionInput"); - $s->runArray([null]); - } - - /** @dataProvider provideDrivers */ - public function testMismatchTypes(bool $driverTestable, bool $stringCoersion, string $class, \Closure $func) { - if (!$driverTestable) { - $this->markTestSkipped(); - } - (new $class(...$func("CREATE TABLE if not exists arsse_feeds(id integer primary key not null, url text not null)")))->run(); - $s = new $class(...$func("INSERT INTO arsse_feeds(id,url) values(?,?)", ["str", "str"])); - $this->assertException("typeViolation", "Db", "ExceptionInput"); - $s->runArray(['ook', 'eek']); - } - - 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")); - $tests = [ - /* input, type, expected binding as SQL fragment */ - 'Null as integer' => [null, "integer", "null"], - 'Null as float' => [null, "float", "null"], - 'Null as string' => [null, "string", "null"], - 'Null as binary' => [null, "binary", "null"], - 'Null as datetime' => [null, "datetime", "null"], - 'Null as boolean' => [null, "boolean", "null"], - 'Null as strict integer' => [null, "strict integer", "0"], - 'Null as strict float' => [null, "strict float", "0.0", "'0'"], - 'Null as strict string' => [null, "strict string", "''"], - 'Null as strict binary' => [null, "strict binary", "x''"], - 'Null as strict datetime' => [null, "strict datetime", "'1970-01-01 00:00:00'"], - 'Null as strict boolean' => [null, "strict boolean", "0"], - 'True as integer' => [true, "integer", "1"], - 'True as float' => [true, "float", "1.0", "'1'"], - 'True as string' => [true, "string", "'1'"], - 'True as binary' => [true, "binary", "x'31'"], - 'True as datetime' => [true, "datetime", "null"], - 'True as boolean' => [true, "boolean", "1"], - 'True as strict integer' => [true, "strict integer", "1"], - 'True as strict float' => [true, "strict float", "1.0", "'1'"], - 'True as strict string' => [true, "strict string", "'1'"], - 'True as strict binary' => [true, "strict binary", "x'31'"], - 'True as strict datetime' => [true, "strict datetime", "'1970-01-01 00:00:00'"], - 'True as strict boolean' => [true, "strict boolean", "1"], - 'False as integer' => [false, "integer", "0"], - 'False as float' => [false, "float", "0.0", "'0'"], - 'False as string' => [false, "string", "''"], - 'False as binary' => [false, "binary", "x''"], - 'False as datetime' => [false, "datetime", "null"], - 'False as boolean' => [false, "boolean", "0"], - 'False as strict integer' => [false, "strict integer", "0"], - 'False as strict float' => [false, "strict float", "0.0", "'0'"], - 'False as strict string' => [false, "strict string", "''"], - 'False as strict binary' => [false, "strict binary", "x''"], - 'False as strict datetime' => [false, "strict datetime", "'1970-01-01 00:00:00'"], - 'False as strict boolean' => [false, "strict boolean", "0"], - 'Integer as integer' => [2112, "integer", "2112"], - 'Integer as float' => [2112, "float", "2112.0", "'2112'"], - 'Integer as string' => [2112, "string", "'2112'"], - 'Integer as binary' => [2112, "binary", "x'32313132'"], - 'Integer as datetime' => [2112, "datetime", "'1970-01-01 00:35:12'"], - 'Integer as boolean' => [2112, "boolean", "1"], - 'Integer as strict integer' => [2112, "strict integer", "2112"], - 'Integer as strict float' => [2112, "strict float", "2112.0", "'2112'"], - 'Integer as strict string' => [2112, "strict string", "'2112'"], - 'Integer as strict binary' => [2112, "strict binary", "x'32313132'"], - 'Integer as strict datetime' => [2112, "strict datetime", "'1970-01-01 00:35:12'"], - 'Integer as strict boolean' => [2112, "strict boolean", "1"], - 'Integer zero as integer' => [0, "integer", "0"], - 'Integer zero as float' => [0, "float", "0.0", "'0'"], - 'Integer zero as string' => [0, "string", "'0'"], - 'Integer zero as binary' => [0, "binary", "x'30'"], - 'Integer zero as datetime' => [0, "datetime", "'1970-01-01 00:00:00'"], - 'Integer zero as boolean' => [0, "boolean", "0"], - 'Integer zero as strict integer' => [0, "strict integer", "0"], - 'Integer zero as strict float' => [0, "strict float", "0.0", "'0'"], - 'Integer zero as strict string' => [0, "strict string", "'0'"], - 'Integer zero as strict binary' => [0, "strict binary", "x'30'"], - 'Integer zero as strict datetime' => [0, "strict datetime", "'1970-01-01 00:00:00'"], - 'Integer zero as strict boolean' => [0, "strict boolean", "0"], - 'Float as integer' => [2112.5, "integer", "2112"], - 'Float as float' => [2112.5, "float", "2112.5", "'2112.5'"], - 'Float as string' => [2112.5, "string", "'2112.5'"], - 'Float as binary' => [2112.5, "binary", "x'323131322e35'"], - 'Float as datetime' => [2112.5, "datetime", "'1970-01-01 00:35:12'"], - 'Float as boolean' => [2112.5, "boolean", "1"], - 'Float as strict integer' => [2112.5, "strict integer", "2112"], - 'Float as strict float' => [2112.5, "strict float", "2112.5", "'2112.5'"], - 'Float as strict string' => [2112.5, "strict string", "'2112.5'"], - 'Float as strict binary' => [2112.5, "strict binary", "x'323131322e35'"], - 'Float as strict datetime' => [2112.5, "strict datetime", "'1970-01-01 00:35:12'"], - 'Float as strict boolean' => [2112.5, "strict boolean", "1"], - 'Float zero as integer' => [0.0, "integer", "0"], - 'Float zero as float' => [0.0, "float", "0.0", "'0'"], - 'Float zero as string' => [0.0, "string", "'0'"], - 'Float zero as binary' => [0.0, "binary", "x'30'"], - 'Float zero as datetime' => [0.0, "datetime", "'1970-01-01 00:00:00'"], - 'Float zero as boolean' => [0.0, "boolean", "0"], - 'Float zero as strict integer' => [0.0, "strict integer", "0"], - 'Float zero as strict float' => [0.0, "strict float", "0.", "'0'"], - 'Float zero as strict string' => [0.0, "strict string", "'0'"], - 'Float zero as strict binary' => [0.0, "strict binary", "x'30'"], - 'Float zero as strict datetime' => [0.0, "strict datetime", "'1970-01-01 00:00:00'"], - 'Float zero as strict boolean' => [0.0, "strict boolean", "0"], - 'ASCII string as integer' => ["Random string", "integer", "0"], - 'ASCII string as float' => ["Random string", "float", "0.0", "'0'"], - 'ASCII string as string' => ["Random string", "string", "'Random string'"], - 'ASCII string as binary' => ["Random string", "binary", "x'52616e646f6d20737472696e67'"], - 'ASCII string as datetime' => ["Random string", "datetime", "null"], - 'ASCII string as boolean' => ["Random string", "boolean", "1"], - 'ASCII string as strict integer' => ["Random string", "strict integer", "0"], - 'ASCII string as strict float' => ["Random string", "strict float", "0.0", "'0'"], - 'ASCII string as strict string' => ["Random string", "strict string", "'Random string'"], - 'ASCII string as strict binary' => ["Random string", "strict binary", "x'52616e646f6d20737472696e67'"], - 'ASCII string as strict datetime' => ["Random string", "strict datetime", "'1970-01-01 00:00:00'"], - 'ASCII string as strict boolean' => ["Random string", "strict boolean", "1"], - 'UTF-8 string as integer' => ["\u{e9}", "integer", "0"], - 'UTF-8 string as float' => ["\u{e9}", "float", "0.0", "'0'"], - 'UTF-8 string as string' => ["\u{e9}", "string", "char(233)"], - 'UTF-8 string as binary' => ["\u{e9}", "binary", "x'c3a9'"], - 'UTF-8 string as datetime' => ["\u{e9}", "datetime", "null"], - 'UTF-8 string as boolean' => ["\u{e9}", "boolean", "1"], - 'UTF-8 string as strict integer' => ["\u{e9}", "strict integer", "0"], - 'UTF-8 string as strict float' => ["\u{e9}", "strict float", "0.0", "'0'"], - 'UTF-8 string as strict string' => ["\u{e9}", "strict string", "char(233)"], - 'UTF-8 string as strict binary' => ["\u{e9}", "strict binary", "x'c3a9'"], - 'UTF-8 string as strict datetime' => ["\u{e9}", "strict datetime", "'1970-01-01 00:00:00'"], - 'UTF-8 string as strict boolean' => ["\u{e9}", "strict boolean", "1"], - 'Binary string as integer' => [chr(233).chr(233), "integer", "0"], - 'Binary string as float' => [chr(233).chr(233), "float", "0.0", "'0'"], - 'Binary string as string' => [chr(233).chr(233), "string", "'".chr(233).chr(233)."'"], - 'Binary string as binary' => [chr(233).chr(233), "binary", "x'e9e9'"], - 'Binary string as datetime' => [chr(233).chr(233), "datetime", "null"], - 'Binary string as boolean' => [chr(233).chr(233), "boolean", "1"], - 'Binary string as strict integer' => [chr(233).chr(233), "strict integer", "0"], - 'Binary string as strict float' => [chr(233).chr(233), "strict float", "0.0", "'0'"], - 'Binary string as strict string' => [chr(233).chr(233), "strict string", "'".chr(233).chr(233)."'"], - 'Binary string as strict binary' => [chr(233).chr(233), "strict binary", "x'e9e9'"], - 'Binary string as strict datetime' => [chr(233).chr(233), "strict datetime", "'1970-01-01 00:00:00'"], - 'Binary string as strict boolean' => [chr(233).chr(233), "strict boolean", "1"], - 'ISO 8601 string as integer' => ["2017-01-09T13:11:17", "integer", "0"], - 'ISO 8601 string as float' => ["2017-01-09T13:11:17", "float", "0.0", "'0'"], - 'ISO 8601 string as string' => ["2017-01-09T13:11:17", "string", "'2017-01-09T13:11:17'"], - 'ISO 8601 string as binary' => ["2017-01-09T13:11:17", "binary", "x'323031372d30312d30395431333a31313a3137'"], - 'ISO 8601 string as datetime' => ["2017-01-09T13:11:17", "datetime", "'2017-01-09 13:11:17'"], - 'ISO 8601 string as boolean' => ["2017-01-09T13:11:17", "boolean", "1"], - 'ISO 8601 string as strict integer' => ["2017-01-09T13:11:17", "strict integer", "0"], - 'ISO 8601 string as strict float' => ["2017-01-09T13:11:17", "strict float", "0.0", "'0'"], - 'ISO 8601 string as strict string' => ["2017-01-09T13:11:17", "strict string", "'2017-01-09T13:11:17'"], - 'ISO 8601 string as strict binary' => ["2017-01-09T13:11:17", "strict binary", "x'323031372d30312d30395431333a31313a3137'"], - 'ISO 8601 string as strict datetime' => ["2017-01-09T13:11:17", "strict datetime", "'2017-01-09 13:11:17'"], - 'ISO 8601 string as strict boolean' => ["2017-01-09T13:11:17", "strict boolean", "1"], - 'Arbitrary date string as integer' => ["Today", "integer", "0"], - 'Arbitrary date string as float' => ["Today", "float", "0.0", "'0'"], - 'Arbitrary date string as string' => ["Today", "string", "'Today'"], - 'Arbitrary date string as binary' => ["Today", "binary", "x'546f646179'"], - 'Arbitrary date string as datetime' => ["Today", "datetime", "'".date_create("Today", new \DateTimezone("UTC"))->format("Y-m-d H:i:s")."'"], - 'Arbitrary date string as boolean' => ["Today", "boolean", "1"], - 'Arbitrary date string as strict integer' => ["Today", "strict integer", "0"], - 'Arbitrary date string as strict float' => ["Today", "strict float", "0.0", "'0'"], - 'Arbitrary date string as strict string' => ["Today", "strict string", "'Today'"], - 'Arbitrary date string as strict binary' => ["Today", "strict binary", "x'546f646179'"], - 'Arbitrary date string as strict datetime' => ["Today", "strict datetime", "'".date_create("Today", new \DateTimezone("UTC"))->format("Y-m-d H:i:s")."'"], - 'Arbitrary date string as strict boolean' => ["Today", "strict boolean", "1"], - 'DateTime as integer' => [$dateMutable, "integer", $dateUTC->getTimestamp()], - 'DateTime as float' => [$dateMutable, "float", $dateUTC->getTimestamp().".0", "'".$dateUTC->getTimestamp()."'"], - 'DateTime as string' => [$dateMutable, "string", "'".$dateUTC->format("Y-m-d H:i:s")."'"], - 'DateTime as binary' => [$dateMutable, "binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"], - 'DateTime as datetime' => [$dateMutable, "datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"], - 'DateTime as boolean' => [$dateMutable, "boolean", "1"], - 'DateTime as strict integer' => [$dateMutable, "strict integer", $dateUTC->getTimestamp()], - 'DateTime as strict float' => [$dateMutable, "strict float", $dateUTC->getTimestamp().".0", "'".$dateUTC->getTimestamp()."'"], - 'DateTime as strict string' => [$dateMutable, "strict string", "'".$dateUTC->format("Y-m-d H:i:s")."'"], - 'DateTime as strict binary' => [$dateMutable, "strict binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"], - 'DateTime as strict datetime' => [$dateMutable, "strict datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"], - 'DateTime as strict boolean' => [$dateMutable, "strict boolean", "1"], - 'DateTimeImmutable as integer' => [$dateImmutable, "integer", $dateUTC->getTimestamp()], - 'DateTimeImmutable as float' => [$dateImmutable, "float", $dateUTC->getTimestamp().".0", "'".$dateUTC->getTimestamp()."'"], - 'DateTimeImmutable as string' => [$dateImmutable, "string", "'".$dateUTC->format("Y-m-d H:i:s")."'"], - 'DateTimeImmutable as binary' => [$dateImmutable, "binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"], - 'DateTimeImmutable as datetime' => [$dateImmutable, "datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"], - 'DateTimeImmutable as boolean' => [$dateImmutable, "boolean", "1"], - 'DateTimeImmutable as strict integer' => [$dateImmutable, "strict integer", $dateUTC->getTimestamp()], - 'DateTimeImmutable as strict float' => [$dateImmutable, "strict float", $dateUTC->getTimestamp().".0", "'".$dateUTC->getTimestamp()."'"], - 'DateTimeImmutable as strict string' => [$dateImmutable, "strict string", "'".$dateUTC->format("Y-m-d H:i:s")."'"], - 'DateTimeImmutable as strict binary' => [$dateImmutable, "strict binary", "x'".bin2hex($dateUTC->format("Y-m-d H:i:s"))."'"], - 'DateTimeImmutable as strict datetime' => [$dateImmutable, "strict datetime", "'".$dateUTC->format("Y-m-d H:i:s")."'"], - 'DateTimeImmutable as strict boolean' => [$dateImmutable, "strict boolean", "1"], - ]; - foreach ($this->provideDrivers() as $drvName => list($drv, $stringCoersion, $class, $func)) { - foreach ($tests as $index => $test) { - if (sizeof($test) > 3) { - list($value, $type, $exp, $expPDO) = $test; - } else { - list($value, $type, $exp) = $test; - $expPDO = null; - } - yield "$index ($drvName)" => [$drv, $class, $func, $value, $type, $exp, $expPDO]; - } - } - } -} diff --git a/tests/cases/Db/TestTransaction.php b/tests/cases/Db/TestTransaction.php index 9469d6c..22b445a 100644 --- a/tests/cases/Db/TestTransaction.php +++ b/tests/cases/Db/TestTransaction.php @@ -16,7 +16,7 @@ class TestTransaction extends \JKingWeb\Arsse\Test\AbstractTest { protected $drv; public function setUp() { - $this->clearData(); + self::clearData(); $drv = Phake::mock(\JKingWeb\Arsse\Db\SQLite3\Driver::class); Phake::when($drv)->savepointRelease->thenReturn(true); Phake::when($drv)->savepointUndo->thenReturn(true); diff --git a/tests/cases/Exception/TestException.php b/tests/cases/Exception/TestException.php index f77ce37..05d1d92 100644 --- a/tests/cases/Exception/TestException.php +++ b/tests/cases/Exception/TestException.php @@ -15,7 +15,7 @@ use Phake; /** @covers \JKingWeb\Arsse\AbstractException */ class TestException extends \JKingWeb\Arsse\Test\AbstractTest { public function setUp() { - $this->clearData(false); + self::clearData(false); // create a mock Lang object so as not to create a dependency loop Arsse::$lang = Phake::mock(Lang::class); Phake::when(Arsse::$lang)->msg->thenReturn(""); @@ -26,7 +26,7 @@ class TestException extends \JKingWeb\Arsse\Test\AbstractTest { Phake::verify(Arsse::$lang, Phake::atLeast(0))->msg($this->isType("string"), $this->anything()); Phake::verifyNoOtherInteractions(Arsse::$lang); // clean up - $this->clearData(true); + self::clearData(true); } public function testBaseClass() { diff --git a/tests/cases/Feed/TestFeed.php b/tests/cases/Feed/TestFeed.php index a1ca84b..d133ee7 100644 --- a/tests/cases/Feed/TestFeed.php +++ b/tests/cases/Feed/TestFeed.php @@ -95,8 +95,8 @@ class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest { $this->markTestSkipped("Test Web server is not accepting requests"); } $this->base = self::$host."Feed/"; - $this->clearData(); - $this->setConf(); + self::clearData(); + self::setConf(); Arsse::$db = Phake::mock(Database::class); } diff --git a/tests/cases/Feed/TestFetching.php b/tests/cases/Feed/TestFetching.php index 25d4d7c..11602b5 100644 --- a/tests/cases/Feed/TestFetching.php +++ b/tests/cases/Feed/TestFetching.php @@ -25,8 +25,8 @@ class TestFetching extends \JKingWeb\Arsse\Test\AbstractTest { $this->markTestSkipped("Test Web server is not accepting requests"); } $this->base = self::$host."Feed/"; - $this->clearData(); - $this->setConf(); + self::clearData(); + self::setConf(); } public function testHandle400() { diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index 63bf953..07d6adb 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -64,6 +64,9 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } else { $this->assertSame($c->$method, $v[$method], "Context method $method did not return the expected results"); } + // clear the context option + $c->$method(null); + $this->assertFalse($c->$method()); } } diff --git a/tests/cases/Misc/TestDate.php b/tests/cases/Misc/TestDate.php index e82f6c8..7fdae60 100644 --- a/tests/cases/Misc/TestDate.php +++ b/tests/cases/Misc/TestDate.php @@ -11,7 +11,7 @@ use JKingWeb\Arsse\Misc\Date; /** @covers \JKingWeb\Arsse\Misc\Date */ class TestDate extends \JKingWeb\Arsse\Test\AbstractTest { public function setUp() { - $this->clearData(); + self::clearData(); } public function testNormalizeADate() { diff --git a/tests/cases/Misc/TestValueInfo.php b/tests/cases/Misc/TestValueInfo.php index e6dbdf4..60c7da0 100644 --- a/tests/cases/Misc/TestValueInfo.php +++ b/tests/cases/Misc/TestValueInfo.php @@ -14,7 +14,7 @@ use JKingWeb\Arsse\Test\Result; /** @covers \JKingWeb\Arsse\Misc\ValueInfo */ class TestValueInfo extends \JKingWeb\Arsse\Test\AbstractTest { public function setUp() { - $this->clearData(); + self::clearData(); } public function testGetIntegerInfo() { @@ -411,26 +411,36 @@ class TestValueInfo extends \JKingWeb\Arsse\Test\AbstractTest { [I::T_STRING, "String", ], [I::T_ARRAY, "Array", ], ]; + $assert = function($exp, $act, string $msg) { + if (is_null($exp)) { + $this->assertNull($act, $msg); + } elseif (is_float($exp) && is_nan($exp)) { + $this->assertNan($act, $msg); + } elseif (is_scalar($exp)) { + $this->assertSame($exp, $act, $msg); + } else { + $this->assertEquals($exp, $act, $msg); + } + }; foreach ($params as $index => $param) { list($type, $name) = $param; - $this->assertNull(I::normalize(null, $type | I::M_STRICT | I::M_NULL), $name." null-passthrough test failed"); + $assert(null, I::normalize(null, $type | I::M_STRICT | I::M_NULL), $name." null-passthrough test failed"); foreach ($tests as $test) { list($exp, $pass) = $index ? $test[$index] : [$test[$index], true]; $value = $test[0]; - $assert = (is_float($exp) && is_nan($exp) ? "assertNan" : (is_scalar($exp) ? "assertSame" : "assertEquals")); - $this->$assert($exp, I::normalize($value, $type), $name." test failed for value: ".var_export($value, true)); + $assert($exp, I::normalize($value, $type), $name." test failed for value: ".var_export($value, true)); if ($pass) { - $this->$assert($exp, I::normalize($value, $type | I::M_DROP), $name." drop test failed for value: ".var_export($value, true)); - $this->$assert($exp, I::normalize($value, $type | I::M_STRICT), $name." error test failed for value: ".var_export($value, true)); + $assert($exp, I::normalize($value, $type | I::M_DROP), $name." drop test failed for value: ".var_export($value, true)); + $assert($exp, I::normalize($value, $type | I::M_STRICT), $name." error test failed for value: ".var_export($value, true)); } else { - $this->assertNull(I::normalize($value, $type | I::M_DROP), $name." drop test failed for value: ".var_export($value, true)); + $assert(null, I::normalize($value, $type | I::M_DROP), $name." drop test failed for value: ".var_export($value, true)); $exc = new ExceptionType("strictFailure", $type); try { $act = I::normalize($value, $type | I::M_STRICT); } catch (ExceptionType $e) { $act = $e; } finally { - $this->assertEquals($exc, $act, $name." error test failed for value: ".var_export($value, true)); + $assert($exc, $act, $name." error test failed for value: ".var_export($value, true)); } } } diff --git a/tests/cases/REST/NextCloudNews/TestV1_2.php b/tests/cases/REST/NextCloudNews/TestV1_2.php index 90375b7..6e9b90a 100644 --- a/tests/cases/REST/NextCloudNews/TestV1_2.php +++ b/tests/cases/REST/NextCloudNews/TestV1_2.php @@ -339,8 +339,8 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { } public function setUp() { - $this->clearData(); - $this->setConf(); + self::clearData(); + self::setConf(); // create a mock user manager Arsse::$user = Phake::mock(User::class); Phake::when(Arsse::$user)->auth->thenReturn(true); @@ -352,7 +352,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { } public function tearDown() { - $this->clearData(); + self::clearData(); } protected function v($value) { @@ -734,11 +734,11 @@ 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(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")); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, $this->anything(), $this->anything())->thenReturn(new Result($this->v($this->articles['db']))); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), $this->anything())->thenThrow(new ExceptionInput("idMissing")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), $this->anything())->thenThrow(new ExceptionInput("idMissing")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), $this->anything())->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), $this->anything())->thenThrow(new ExceptionInput("typeViolation")); $exp = new Response(['items' => $this->articles['rest']]); // check the contents of the response $this->assertMessage($exp, $this->req("GET", "/items")); // first instance of base context @@ -759,17 +759,17 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $this->req("GET", "/items", json_encode($in[10])); $this->req("GET", "/items", json_encode($in[11])); // perform method verifications - Phake::verify(Arsse::$db, Phake::times(4))->articleList(Arsse::$user->id, (new Context)->reverse(true), Database::LIST_TYPICAL); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), Database::LIST_TYPICAL); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), Database::LIST_TYPICAL); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), Database::LIST_TYPICAL); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), Database::LIST_TYPICAL); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->starred(true), Database::LIST_TYPICAL); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(false)->limit(10)->oldestEdition(6), Database::LIST_TYPICAL); // offset is one more than specified - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5)->latestEdition(4), Database::LIST_TYPICAL); // offset is one less than specified - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->unread(true), Database::LIST_TYPICAL); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->markedSince($t), Database::LIST_TYPICAL); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5), Database::LIST_TYPICAL); + Phake::verify(Arsse::$db, Phake::times(4))->articleList(Arsse::$user->id, (new Context)->reverse(true), $this->anything()); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), $this->anything()); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), $this->anything()); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), $this->anything()); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), $this->anything()); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->starred(true), $this->anything()); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(false)->limit(10)->oldestEdition(6), $this->anything()); // offset is one more than specified + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5)->latestEdition(4), $this->anything()); // offset is one less than specified + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->unread(true), $this->anything()); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $this->equalTo((new Context)->reverse(true)->markedSince($t), 2), $this->anything()); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5), $this->anything()); } public function testMarkAFolderRead() { @@ -958,6 +958,6 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $url = "/items?type=2"; Phake::when(Arsse::$db)->articleList->thenReturn(new Result([])); $this->req("GET", $url, json_encode($in)); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(false)->starred(true), Database::LIST_TYPICAL); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(false)->starred(true), $this->anything()); } } diff --git a/tests/cases/REST/NextCloudNews/TestVersions.php b/tests/cases/REST/NextCloudNews/TestVersions.php index 28c6e0c..c803f8d 100644 --- a/tests/cases/REST/NextCloudNews/TestVersions.php +++ b/tests/cases/REST/NextCloudNews/TestVersions.php @@ -15,7 +15,7 @@ use Zend\Diactoros\Response\EmptyResponse; /** @covers \JKingWeb\Arsse\REST\NextCloudNews\Versions */ class TestVersions extends \JKingWeb\Arsse\Test\AbstractTest { public function setUp() { - $this->clearData(); + self::clearData(); } protected function req(string $method, string $target): ResponseInterface { diff --git a/tests/cases/REST/TestREST.php b/tests/cases/REST/TestREST.php index fcc4965..43799d1 100644 --- a/tests/cases/REST/TestREST.php +++ b/tests/cases/REST/TestREST.php @@ -95,7 +95,7 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { } public function testSendAuthenticationChallenges() { - $this->setConf(); + self::setConf(); $r = new REST(); $in = new EmptyResponse(401); $exp = $in->withHeader("WWW-Authenticate", 'Basic realm="OOK"'); @@ -151,9 +151,9 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideCorsNegotiations */ public function testNegotiateCors($origin, bool $exp, string $allowed = null, string $denied = null) { - $this->setConf(); + self::setConf(); $r = Phake::partialMock(REST::class); - Phake::when($r)->corsNormalizeOrigin->thenReturnCallback(function ($origin) { + Phake::when($r)->corsNormalizeOrigin->thenReturnCallback(function($origin) { return $origin; }); $headers = isset($origin) ? ['Origin' => $origin] : []; @@ -255,10 +255,10 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { public function testNormalizeHttpResponses(ResponseInterface $res, ResponseInterface $exp, RequestInterface $req = null) { $r = Phake::partialMock(REST::class); Phake::when($r)->corsNegotiate->thenReturn(true); - Phake::when($r)->challenge->thenReturnCallback(function ($res) { + Phake::when($r)->challenge->thenReturnCallback(function($res) { return $res->withHeader("WWW-Authenticate", "Fake Value"); }); - Phake::when($r)->corsApply->thenReturnCallback(function ($res) { + Phake::when($r)->corsApply->thenReturnCallback(function($res) { return $res; }); $act = $r->normalizeResponse($res, $req); @@ -298,10 +298,10 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideMockRequests */ public function testDispatchRequests(ServerRequest $req, string $method, bool $called, string $class = "", string $target ="") { $r = Phake::partialMock(REST::class); - Phake::when($r)->normalizeResponse->thenReturnCallback(function ($res) { + Phake::when($r)->normalizeResponse->thenReturnCallback(function($res) { return $res; }); - Phake::when($r)->authenticateRequest->thenReturnCallback(function ($req) { + Phake::when($r)->authenticateRequest->thenReturnCallback(function($req) { return $req; }); if ($called) { diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index b6bfb4f..8ba992b 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -176,8 +176,8 @@ LONG_STRING; } public function setUp() { - $this->clearData(); - $this->setConf(); + self::clearData(); + self::setConf(); // create a mock user manager Arsse::$user = Phake::mock(User::class); Phake::when(Arsse::$user)->auth->thenReturn(true); @@ -196,7 +196,7 @@ LONG_STRING; } public function tearDown() { - $this->clearData(); + self::clearData(); } public function testHandleInvalidPaths() { @@ -225,7 +225,7 @@ LONG_STRING; /** @dataProvider provideLoginRequests */ public function testLogIn(array $conf, $httpUser, array $data, $sessions) { Arsse::$user->id = null; - $this->setConf($conf); + self::setConf($conf); Phake::when(Arsse::$user)->auth->thenReturn(false); Phake::when(Arsse::$user)->auth("john.doe@example.com", "secret")->thenReturn(true); Phake::when(Arsse::$user)->auth("jane.doe@example.com", "superman")->thenReturn(true); @@ -259,7 +259,7 @@ LONG_STRING; /** @dataProvider provideResumeRequests */ public function testValidateASession(array $conf, $httpUser, string $data, $result) { Arsse::$user->id = null; - $this->setConf($conf); + self::setConf($conf); Phake::when(Arsse::$db)->sessionResume("PriestsOfSyrinx")->thenReturn([ 'id' => "PriestsOfSyrinx", 'created' => "2000-01-01 00:00:00", @@ -1133,7 +1133,7 @@ LONG_STRING; 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)->articleCount($this->anything(), $this->equalTo((new Context)->unread(true)->modifiedSince(Date::sub("PT24H")), 2))->thenReturn(7); Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($this->starred)); $exp = [ [ @@ -1197,7 +1197,7 @@ LONG_STRING; 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)->articleCount($this->anything(), $this->equalTo((new Context)->unread(true)->modifiedSince(Date::sub("PT24H")), 2))->thenReturn(7); Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($this->starred)); $exp = [ ['id' => "global-unread", 'counter' => 35], @@ -1318,7 +1318,7 @@ LONG_STRING; 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)->articleCount($this->anything(), $this->equalTo((new Context)->unread(true)->modifiedSince(Date::sub("PT24H")), 2))->thenReturn(7); 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,],],],],],]; @@ -1375,7 +1375,7 @@ LONG_STRING; for ($a = 0; $a < sizeof($in3); $a++) { $this->assertMessage($exp, $this->req($in3[$a]), "Test $a failed"); } - Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->modifiedSince($t)); + Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], $this->equalTo((new Context)->modifiedSince($t), 2)); // within two seconds } public function testRetrieveFeedList() { @@ -1405,7 +1405,7 @@ LONG_STRING; ]; // statistical mocks 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(), $this->equalTo((new Context)->unread(true)->modifiedSince(Date::sub("PT24H")), 2))->thenReturn(7); 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->v($this->labels))); @@ -1516,13 +1516,13 @@ LONG_STRING; } protected function filterFolders(int $id = null): array { - return array_filter($this->folders, function ($value) use ($id) { + return array_filter($this->folders, function($value) use ($id) { return $value['parent']==$id; }); } protected function filterSubs(int $folder = null): array { - return array_filter($this->subscriptions, function ($value) use ($folder) { + return array_filter($this->subscriptions, function($value) use ($folder) { return $value['folder']==$folder; }); } @@ -1532,9 +1532,9 @@ LONG_STRING; foreach ($this->filterFolders($id) as $f) { $out += $this->reduceFolders($f['id']); } - $out += array_reduce(array_filter($this->subscriptions, function ($value) use ($id) { + $out += array_reduce(array_filter($this->subscriptions, function($value) use ($id) { return $value['folder']==$id; - }), function ($sum, $value) { + }), function($sum, $value) { return $sum + $value['unread']; }, 0); return $out; @@ -1641,9 +1641,9 @@ LONG_STRING; 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($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]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([101, 102]), $this->anything())->thenReturn(new Result($this->v($this->articles))); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([101]), $this->anything())->thenReturn(new Result($this->v([$this->articles[0]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([102]), $this->anything())->thenReturn(new Result($this->v([$this->articles[1]]))); $exp = $this->respErr("INCORRECT_USAGE"); $this->assertMessage($exp, $this->req($in[0])); $this->assertMessage($exp, $this->req($in[1])); @@ -1750,18 +1750,18 @@ LONG_STRING; 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->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]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), ["id"])->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->articleList($this->anything(), $c, ["id"])->thenReturn(new Result($this->v($this->articles))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), ["id"])->thenReturn(new Result($this->v([['id' => 1]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), ["id"])->thenReturn(new Result($this->v([['id' => 2]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), ["id"])->thenReturn(new Result($this->v([['id' => 3]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), ["id"])->thenReturn(new Result($this->v([['id' => 4]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), ["id"])->thenReturn(new Result($this->v([['id' => 5]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), ["id"])->thenReturn(new Result($this->v([['id' => 6]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), ["id"])->thenReturn(new Result($this->v([['id' => 7]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), ["id"])->thenReturn(new Result($this->v([['id' => 8]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), ["id"])->thenReturn(new Result($this->v([['id' => 9]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), ["id"])->thenReturn(new Result($this->v([['id' => 10]]))); $out1 = [ $this->respErr("INCORRECT_USAGE"), $this->respGood([]), @@ -1793,9 +1793,9 @@ LONG_STRING; $this->assertMessage($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($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]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(false)->markedSince(Date::sub("PT24H")), 2), ["id"])->thenReturn(new Result($this->v([['id' => 1001]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), 2), ["id"])->thenReturn(new Result($this->v([['id' => 1002]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), 2), ["id"])->thenReturn(new Result($this->v([['id' => 1003]]))); $this->assertMessage($out2[$a], $this->req($in2[$a]), "Test $a failed"); } } @@ -1851,23 +1851,23 @@ LONG_STRING; Phake::when(Arsse::$db)->articleCount->thenReturn(0); Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1); $c = (new Context)->limit(200)->reverse(true); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), Database::LIST_FULL)->thenThrow(new ExceptionInput("subjectMissing")); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), Database::LIST_FULL)->thenReturn($this->generateHeadlines(2)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(3)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(4)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(5)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(6)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), Database::LIST_FULL)->thenReturn($this->generateHeadlines(7)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), Database::LIST_FULL)->thenReturn($this->generateHeadlines(8)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), Database::LIST_FULL)->thenReturn($this->generateHeadlines(9)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), Database::LIST_FULL)->thenReturn($this->generateHeadlines(10)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c), Database::LIST_FULL)->thenReturn($this->generateHeadlines(11)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->labelled(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(12)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(0), Database::LIST_FULL)->thenReturn($this->generateHeadlines(13)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(42), Database::LIST_FULL)->thenReturn($this->generateHeadlines(14)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folder(42), Database::LIST_FULL)->thenReturn($this->generateHeadlines(15)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->reverse(false), Database::LIST_FULL)->thenReturn($this->generateHeadlines(16)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), $this->anything())->thenReturn($this->generateHeadlines(1)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), $this->anything())->thenReturn($this->generateHeadlines(2)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), $this->anything())->thenReturn($this->generateHeadlines(3)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), $this->anything())->thenReturn($this->generateHeadlines(4)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), $this->anything())->thenReturn($this->generateHeadlines(5)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), $this->anything())->thenReturn($this->generateHeadlines(6)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), $this->anything())->thenReturn($this->generateHeadlines(7)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), $this->anything())->thenReturn($this->generateHeadlines(8)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), $this->anything())->thenReturn($this->generateHeadlines(9)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), $this->anything())->thenReturn($this->generateHeadlines(10)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c), $this->anything())->thenReturn($this->generateHeadlines(11)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->labelled(true), $this->anything())->thenReturn($this->generateHeadlines(12)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(0), $this->anything())->thenReturn($this->generateHeadlines(13)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(42), $this->anything())->thenReturn($this->generateHeadlines(14)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folder(42), $this->anything())->thenReturn($this->generateHeadlines(15)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->reverse(false), $this->anything())->thenReturn($this->generateHeadlines(16)); $out2 = [ $this->respErr("INCORRECT_USAGE"), $this->outputHeadlines(11), @@ -1904,9 +1904,9 @@ LONG_STRING; $this->assertMessage($out2[$a], $this->req($in2[$a]), "Test $a failed"); } for ($a = 0; $a < sizeof($in3); $a++) { - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(false)->markedSince(Date::sub("PT24H")), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1001)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1002)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1003)); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(false)->markedSince(Date::sub("PT24H")), 2), $this->anything())->thenReturn($this->generateHeadlines(1001)); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), 2), $this->anything())->thenReturn($this->generateHeadlines(1002)); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), 2), $this->anything())->thenReturn($this->generateHeadlines(1003)); $this->assertMessage($out3[$a], $this->req($in3[$a]), "Test $a failed"); } } @@ -2000,7 +2000,7 @@ LONG_STRING; ]); $this->assertMessage($exp, $test); // test 'include_header' with skip - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->reverse(true)->limit(1)->subscription(42), Database::LIST_MINIMAL)->thenReturn($this->generateHeadlines(1867)); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->reverse(true)->limit(1)->subscription(42), $this->anything())->thenReturn($this->generateHeadlines(1867)); $test = $this->req($in[8]); $exp = $this->respGood([ ['id' => 42, 'is_cat' => false, 'first_id' => 1867], diff --git a/tests/cases/REST/TinyTinyRSS/TestIcon.php b/tests/cases/REST/TinyTinyRSS/TestIcon.php index 53db57c..bacf3be 100644 --- a/tests/cases/REST/TinyTinyRSS/TestIcon.php +++ b/tests/cases/REST/TinyTinyRSS/TestIcon.php @@ -23,8 +23,8 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest { protected $user = "john.doe@example.com"; public function setUp() { - $this->clearData(); - $this->setConf(); + self::clearData(); + self::setConf(); // create a mock user manager Arsse::$user = Phake::mock(User::class); // create a mock database interface @@ -33,7 +33,7 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest { } public function tearDown() { - $this->clearData(); + self::clearData(); } protected function req(string $target, string $method = "GET", string $user = null): ResponseInterface { @@ -108,7 +108,7 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertMessage($exp, $this->reqAuthFailed("42.ico")); $this->assertMessage($exp, $this->reqAuthFailed("1337.ico")); // with HTTP auth required, only authenticated requests should succeed - $this->setConf(['userHTTPAuthRequired' => true]); + self::setConf(['userHTTPAuthRequired' => true]); $exp = new Response(301, ['Location' => "http://example.org/icon.gif"]); $this->assertMessage($exp, $this->reqAuth("42.ico")); $this->assertMessage($exp, $this->reqAuth("1337.ico")); diff --git a/tests/cases/Service/TestService.php b/tests/cases/Service/TestService.php index 3ba18e0..4373c63 100644 --- a/tests/cases/Service/TestService.php +++ b/tests/cases/Service/TestService.php @@ -18,8 +18,8 @@ class TestService extends \JKingWeb\Arsse\Test\AbstractTest { protected $srv; public function setUp() { - $this->clearData(); - $this->setConf(); + self::clearData(); + self::setConf(); Arsse::$db = Phake::mock(Database::class); $this->srv = new Service(); } diff --git a/tests/cases/User/TestInternal.php b/tests/cases/User/TestInternal.php index 68b597d..f7f042d 100644 --- a/tests/cases/User/TestInternal.php +++ b/tests/cases/User/TestInternal.php @@ -17,10 +17,9 @@ use Phake; /** @covers \JKingWeb\Arsse\User\Internal\Driver */ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest { - public function setUp() { - $this->clearData(); - $this->setConf(); + self::clearData(); + self::setConf(); // create a mock database interface Arsse::$db = Phake::mock(Database::class); Phake::when(Arsse::$db)->begin->thenReturn(Phake::mock(\JKingWeb\Arsse\Db\Transaction::class)); @@ -34,8 +33,8 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertTrue(strlen(Driver::driverName()) > 0); } - /** - * @dataProvider provideAuthentication + /** + * @dataProvider provideAuthentication * @group slow */ public function testAuthenticateAUser(bool $authorized, string $user, string $password, bool $exp) { diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php index 806bfff..abe4b5a 100644 --- a/tests/cases/User/TestUser.php +++ b/tests/cases/User/TestUser.php @@ -17,10 +17,9 @@ use Phake; /** @covers \JKingWeb\Arsse\User */ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest { - public function setUp() { - $this->clearData(); - $this->setConf(); + self::clearData(); + self::setConf(); // create a mock database interface Arsse::$db = Phake::mock(Database::class); Phake::when(Arsse::$db)->begin->thenReturn(Phake::mock(\JKingWeb\Arsse\Db\Transaction::class)); @@ -236,7 +235,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertException("doesNotExist", "User"); } $calls = 0; - } else{ + } else { $calls = 1; } try { diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index a75c339..9ec5ae5 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -21,14 +21,14 @@ use Zend\Diactoros\Response\EmptyResponse; /** @coversNothing */ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { public function setUp() { - $this->clearData(); + self::clearData(); } public function tearDown() { - $this->clearData(); + self::clearData(); } - public function clearData(bool $loadLang = true) { + public static function clearData(bool $loadLang = true) { date_default_timezone_set("America/Toronto"); $r = new \ReflectionClass(\JKingWeb\Arsse\Arsse::class); $props = array_keys($r->getStaticProperties()); @@ -40,8 +40,16 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { } } - public function setConf(array $conf = []) { - Arsse::$conf = (new Conf)->import($conf); + public static function setConf(array $conf = [], bool $force = true) { + $defaults = [ + 'dbSQLite3File' => ":memory:", + 'dbSQLite3Timeout' => 0, + 'dbPostgreSQLUser' => "arsse_test", + 'dbPostgreSQLPass' => "arsse_test", + 'dbPostgreSQLDb' => "arsse_test", + 'dbPostgreSQLSchema' => "arsse_test", + ]; + Arsse::$conf = ($force ? null : Arsse::$conf) ?? (new Conf)->import($defaults)->import($conf); } public function assertException(string $msg = "", string $prefix = "", string $type = "Exception") { @@ -61,7 +69,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { } } - protected function assertMessage(MessageInterface $exp, MessageInterface $act, string $text = null) { + protected function assertMessage(MessageInterface $exp, MessageInterface $act, string $text = '') { if ($exp instanceof ResponseInterface) { $this->assertInstanceOf(ResponseInterface::class, $act, $text); $this->assertEquals($exp->getStatusCode(), $act->getStatusCode(), $text); @@ -83,7 +91,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { $this->assertEquals($exp->getHeaders(), $act->getHeaders(), $text); } - public function assertTime($exp, $test, string $msg = null) { + public function assertTime($exp, $test, string $msg = '') { $test = $this->approximateTime($exp, $test); $exp = Date::transform($exp, "iso8601"); $test = Date::transform($test, "iso8601"); diff --git a/tests/lib/Database/DriverSQLite3.php b/tests/lib/Database/DriverSQLite3.php deleted file mode 100644 index 1b76eea..0000000 --- a/tests/lib/Database/DriverSQLite3.php +++ /dev/null @@ -1,24 +0,0 @@ -markTestSkipped("SQLite extension not loaded"); - } - Arsse::$conf->dbSQLite3File = ":memory:"; - $this->drv = new Driver(); - } - - public function nextID(string $table): int { - return $this->drv->query("SELECT (case when max(id) then max(id) else 0 end)+1 from $table")->getValue(); - } -} diff --git a/tests/lib/Database/DriverSQLite3PDO.php b/tests/lib/Database/DriverSQLite3PDO.php deleted file mode 100644 index 9c52bd8..0000000 --- a/tests/lib/Database/DriverSQLite3PDO.php +++ /dev/null @@ -1,24 +0,0 @@ -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/DatabaseInformation.php b/tests/lib/DatabaseInformation.php new file mode 100644 index 0000000..ea70afc --- /dev/null +++ b/tests/lib/DatabaseInformation.php @@ -0,0 +1,249 @@ +name = $name; + foreach (self::$data[$name] as $key => $value) { + $this->$key = $value; + } + } + + public static function list(): array { + if (!isset(self::$data)) { + self::$data = self::getData(); + } + return array_keys(self::$data); + } + + public static function listPDO(): array { + if (!isset(self::$data)) { + self::$data = self::getData(); + } + return array_values(array_filter(array_keys(self::$data), function($k) { + return self::$data[$k]['pdo']; + })); + } + + protected static function getData() { + $sqlite3TableList = function($db): array { + $listTables = "SELECT name from sqlite_master where type = 'table' and name like 'arsse_%'"; + if ($db instanceof Driver) { + $tables = $db->query($listTables)->getAll(); + $tables = sizeof($tables) ? array_column($tables, "name") : []; + } elseif ($db instanceof \PDO) { + retry: + try { + $tables = $db->query($listTables)->fetchAll(\PDO::FETCH_ASSOC); + } catch (\PDOException $e) { + goto retry; + } + $tables = sizeof($tables) ? array_column($tables, "name") : []; + } else { + $tables = []; + $result = $db->query($listTables); + while ($r = $result->fetchArray(\SQLITE3_ASSOC)) { + $tables[] = $r['name']; + } + $result->finalize(); + } + return $tables; + }; + $sqlite3TruncateFunction = function($db, array $afterStatements = []) use ($sqlite3TableList) { + // rollback any pending transaction + try { + $db->exec("ROLLBACK"); + } catch (\Throwable $e) { + } + foreach ($sqlite3TableList($db) as $table) { + if ($table == "arsse_meta") { + $db->exec("DELETE FROM $table where key <> 'schema_version'"); + } else { + $db->exec("DELETE FROM $table"); + } + } + foreach ($afterStatements as $st) { + $db->exec($st); + } + }; + $sqlite3RazeFunction = function($db, array $afterStatements = []) use ($sqlite3TableList) { + // rollback any pending transaction + try { + $db->exec("ROLLBACK"); + } catch (\Throwable $e) { + } + $db->exec("PRAGMA foreign_keys=0"); + foreach ($sqlite3TableList($db) as $table) { + $db->exec("DROP TABLE IF EXISTS $table"); + } + $db->exec("PRAGMA user_version=0"); + $db->exec("PRAGMA foreign_keys=1"); + foreach ($afterStatements as $st) { + $db->exec($st); + } + }; + $pgObjectList = function($db): array { + $listObjects = "SELECT table_name as name, 'TABLE' as type from information_schema.tables where table_schema = current_schema() and table_name like 'arsse_%' union SELECT collation_name as name, 'COLLATION' as type from information_schema.collations where collation_schema = current_schema()"; + if ($db instanceof Driver) { + return $db->query($listObjects)->getAll(); + } elseif ($db instanceof \PDO) { + return $db->query($listObjects)->fetchAll(\PDO::FETCH_ASSOC); + } else { + $r = @pg_query($db, $listObjects); + $out = $r ? pg_fetch_all($r) : false; + return $out ? $out : []; + } + }; + $pgExecFunction = function($db, $q) { + if ($db instanceof Driver) { + $db->exec($q); + } elseif ($db instanceof \PDO) { + $db->exec($q); + } else { + pg_query($db, $q); + } + }; + $pgTruncateFunction = function($db, array $afterStatements = []) use ($pgObjectList, $pgExecFunction) { + // rollback any pending transaction + try { + @$pgExecFunction($db, "ROLLBACK"); + } catch (\Throwable $e) { + } + foreach ($pgObjectList($db) as $obj) { + if ($obj['type'] != "TABLE") { + continue; + } elseif ($obj['name'] == "arsse_meta") { + $pgExecFunction($db, "DELETE FROM {$obj['name']} where key <> 'schema_version'"); + } else { + $pgExecFunction($db, "TRUNCATE TABLE {$obj['name']} restart identity cascade"); + } + } + foreach ($afterStatements as $st) { + $pgExecFunction($db, $st); + } + }; + $pgRazeFunction = function($db, array $afterStatements = []) use ($pgObjectList, $pgExecFunction) { + // rollback any pending transaction + try { + $pgExecFunction($db, "ROLLBACK"); + } catch (\Throwable $e) { + } + foreach ($pgObjectList($db) as $obj) { + $pgExecFunction($db, "DROP {$obj['type']} IF EXISTS {$obj['name']} cascade"); + } + foreach ($afterStatements as $st) { + $pgExecFunction($db, $st); + } + }; + return [ + 'SQLite 3' => [ + 'pdo' => false, + 'backend' => "SQLite 3", + 'statementClass' => \JKingWeb\Arsse\Db\SQLite3\Statement::class, + 'resultClass' => \JKingWeb\Arsse\Db\SQLite3\Result::class, + 'driverClass' => \JKingWeb\Arsse\Db\SQLite3\Driver::class, + 'stringOutput' => false, + 'interfaceConstructor' => function() { + try { + $d = new \SQLite3(Arsse::$conf->dbSQLite3File); + } catch (\Throwable $e) { + return; + } + $d->enableExceptions(true); + return $d; + }, + 'truncateFunction' => $sqlite3TruncateFunction, + 'razeFunction' => $sqlite3RazeFunction, + ], + 'PDO SQLite 3' => [ + 'pdo' => true, + 'backend' => "SQLite 3", + 'statementClass' => \JKingWeb\Arsse\Db\PDOStatement::class, + 'resultClass' => \JKingWeb\Arsse\Db\PDOResult::class, + 'driverClass' => \JKingWeb\Arsse\Db\SQLite3\PDODriver::class, + 'stringOutput' => true, + 'interfaceConstructor' => function() { + try { + $d = new \PDO("sqlite:".Arsse::$conf->dbSQLite3File, "", "", [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + $d->exec("PRAGMA busy_timeout=0"); + return $d; + } catch (\Throwable $e) { + return; + } + }, + 'truncateFunction' => $sqlite3TruncateFunction, + 'razeFunction' => $sqlite3RazeFunction, + ], + 'PostgreSQL' => [ + 'pdo' => false, + 'backend' => "PostgreSQL", + 'statementClass' => \JKingWeb\Arsse\Db\PostgreSQL\Statement::class, + 'resultClass' => \JKingWeb\Arsse\Db\PostgreSQL\Result::class, + 'driverClass' => \JKingWeb\Arsse\Db\PostgreSQL\Driver::class, + 'stringOutput' => true, + 'interfaceConstructor' => function() { + $connString = \JKingWeb\Arsse\Db\PostgreSQL\Driver::makeConnectionString(false, Arsse::$conf->dbPostgreSQLUser, Arsse::$conf->dbPostgreSQLPass, Arsse::$conf->dbPostgreSQLDb, Arsse::$conf->dbPostgreSQLHost, Arsse::$conf->dbPostgreSQLPort, ""); + if ($d = @pg_connect($connString, \PGSQL_CONNECT_FORCE_NEW)) { + foreach (\JKingWeb\Arsse\Db\PostgreSQL\Driver::makeSetupQueries(Arsse::$conf->dbPostgreSQLSchema) as $q) { + pg_query($d, $q); + } + return $d; + } else { + return; + } + }, + 'truncateFunction' => $pgTruncateFunction, + 'razeFunction' => $pgRazeFunction, + ], + 'PDO PostgreSQL' => [ + 'pdo' => true, + 'backend' => "PostgreSQL", + 'statementClass' => \JKingWeb\Arsse\Db\PostgreSQL\PDOStatement::class, + 'resultClass' => \JKingWeb\Arsse\Db\PDOResult::class, + 'driverClass' => \JKingWeb\Arsse\Db\PostgreSQL\PDODriver::class, + 'stringOutput' => false, + 'interfaceConstructor' => function() { + $connString = \JKingWeb\Arsse\Db\PostgreSQL\Driver::makeConnectionString(true, Arsse::$conf->dbPostgreSQLUser, Arsse::$conf->dbPostgreSQLPass, Arsse::$conf->dbPostgreSQLDb, Arsse::$conf->dbPostgreSQLHost, Arsse::$conf->dbPostgreSQLPort, ""); + try { + $d = new \PDO("pgsql:".$connString, Arsse::$conf->dbPostgreSQLUser, Arsse::$conf->dbPostgreSQLPass, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + } catch (\Throwable $e) { + return; + } + foreach (\JKingWeb\Arsse\Db\PostgreSQL\PDODriver::makeSetupQueries(Arsse::$conf->dbPostgreSQLSchema) as $q) { + $d->exec($q); + } + return $d; + }, + 'truncateFunction' => $pgTruncateFunction, + 'razeFunction' => $pgRazeFunction, + ], + ]; + } +} diff --git a/tests/lib/Lang/Setup.php b/tests/lib/Lang/Setup.php index 76843bb..861dd41 100644 --- a/tests/lib/Lang/Setup.php +++ b/tests/lib/Lang/Setup.php @@ -39,7 +39,7 @@ trait Setup { // make the test Lang class use the vfs files $this->l = new TestLang($this->path); // create a mock Lang object so as not to create a dependency loop - $this->clearData(false); + self::clearData(false); Arsse::$lang = Phake::mock(Lang::class); Phake::when(Arsse::$lang)->msg->thenReturn(""); // call the additional setup method if it exists @@ -53,7 +53,7 @@ trait Setup { Phake::verify(Arsse::$lang, Phake::atLeast(0))->msg($this->isType("string"), $this->anything()); Phake::verifyNoOtherInteractions(Arsse::$lang); // clean up - $this->clearData(true); + self::clearData(true); // call the additional teardiwn method if it exists if (method_exists($this, "tearDownSeries")) { $this->tearDownSeries(); diff --git a/tests/phpunit.xml b/tests/phpunit.xml index f2a4967..ceca94a 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -7,7 +7,6 @@ convertWarningsToExceptions="false" beStrictAboutTestsThatDoNotTestAnything="true" beStrictAboutOutputDuringTests="true" - beStrictAboutTestSize="true" stopOnError="true"> @@ -45,39 +44,36 @@ cases/Db/TestTransaction.php cases/Db/TestResultAggregate.php cases/Db/TestResultEmpty.php - cases/Db/TestResult.php - cases/Db/TestStatement.php + cases/Db/SQLite3/TestResult.php + cases/Db/SQLite3/TestStatement.php 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/PostgreSQL/TestResult.php + cases/Db/PostgreSQL/TestStatement.php + cases/Db/PostgreSQL/TestCreation.php + cases/Db/PostgreSQL/TestDriver.php + cases/Db/PostgreSQL/TestUpdate.php + + cases/Db/PostgreSQLPDO/TestResult.php + cases/Db/PostgreSQLPDO/TestStatement.php + cases/Db/PostgreSQLPDO/TestCreation.php + cases/Db/PostgreSQLPDO/TestDriver.php + cases/Db/PostgreSQLPDO/TestUpdate.php - cases/Db/SQLite3/Database/TestMiscellany.php - cases/Db/SQLite3/Database/TestMeta.php - cases/Db/SQLite3/Database/TestUser.php - cases/Db/SQLite3/Database/TestSession.php - cases/Db/SQLite3/Database/TestFolder.php - cases/Db/SQLite3/Database/TestFeed.php - cases/Db/SQLite3/Database/TestSubscription.php - 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/Db/SQLite3/TestDatabase.php + cases/Db/SQLite3PDO/TestDatabase.php + cases/Db/PostgreSQL/TestDatabase.php + cases/Db/PostgreSQLPDO/TestDatabase.php cases/REST/TestTarget.php diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock index 65c2fd7..9a89268 100644 --- a/vendor-bin/csfixer/composer.lock +++ b/vendor-bin/csfixer/composer.lock @@ -423,16 +423,16 @@ }, { "name": "psr/log", - "version": "1.0.2", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "4ebe3a8bf773a19edfe0a84b6585ba3d401b724d" + "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/4ebe3a8bf773a19edfe0a84b6585ba3d401b724d", - "reference": "4ebe3a8bf773a19edfe0a84b6585ba3d401b724d", + "url": "https://api.github.com/repos/php-fig/log/zipball/6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd", + "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd", "shasum": "" }, "require": { @@ -466,7 +466,7 @@ "psr", "psr-3" ], - "time": "2016-10-10T12:19:37+00:00" + "time": "2018-11-20T15:27:04+00:00" }, { "name": "symfony/console", diff --git a/vendor-bin/robo/composer.lock b/vendor-bin/robo/composer.lock index e1106e3..09a7ac7 100644 --- a/vendor-bin/robo/composer.lock +++ b/vendor-bin/robo/composer.lock @@ -8,20 +8,20 @@ "packages": [ { "name": "consolidation/annotated-command", - "version": "2.9.1", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/consolidation/annotated-command.git", - "reference": "4bdbb8fa149e1cc1511bd77b0bc4729fd66bccac" + "reference": "8e7d1a05230dc1159c751809e98b74f2b7f71873" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/4bdbb8fa149e1cc1511bd77b0bc4729fd66bccac", - "reference": "4bdbb8fa149e1cc1511bd77b0bc4729fd66bccac", + "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/8e7d1a05230dc1159c751809e98b74f2b7f71873", + "reference": "8e7d1a05230dc1159c751809e98b74f2b7f71873", "shasum": "" }, "require": { - "consolidation/output-formatters": "^3.1.12", + "consolidation/output-formatters": "^3.4", "php": ">=5.4.0", "psr/log": "^1", "symfony/console": "^2.8|^3|^4", @@ -56,7 +56,7 @@ } ], "description": "Initialize Symfony Console commands from annotated command class methods.", - "time": "2018-09-19T17:47:18+00:00" + "time": "2018-11-15T01:46:18+00:00" }, { "name": "consolidation/config", @@ -219,16 +219,16 @@ }, { "name": "consolidation/robo", - "version": "1.3.1", + "version": "1.3.2", "source": { "type": "git", "url": "https://github.com/consolidation/Robo.git", - "reference": "31f2d2562c4e1dcde70f2659eefd59aa9c7f5b2d" + "reference": "a9bd9ecf00751aa92754903c0d17612c4e840ce8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/Robo/zipball/31f2d2562c4e1dcde70f2659eefd59aa9c7f5b2d", - "reference": "31f2d2562c4e1dcde70f2659eefd59aa9c7f5b2d", + "url": "https://api.github.com/repos/consolidation/Robo/zipball/a9bd9ecf00751aa92754903c0d17612c4e840ce8", + "reference": "a9bd9ecf00751aa92754903c0d17612c4e840ce8", "shasum": "" }, "require": { @@ -237,7 +237,6 @@ "consolidation/log": "~1", "consolidation/output-formatters": "^3.1.13", "consolidation/self-update": "^1", - "g1a/composer-test-scenarios": "^2", "grasmash/yaml-expander": "^1.3", "league/container": "^2.2", "php": ">=5.5.0", @@ -254,14 +253,15 @@ "codeception/aspect-mock": "^1|^2.1.1", "codeception/base": "^2.3.7", "codeception/verify": "^0.3.2", + "g1a/composer-test-scenarios": "^3", "goaop/framework": "~2.1.2", "goaop/parser-reflection": "^1.1.0", "natxet/cssmin": "3.0.4", "nikic/php-parser": "^3.1.5", "patchwork/jsqueeze": "~2", "pear/archive_tar": "^1.4.2", + "php-coveralls/php-coveralls": "^1", "phpunit/php-code-coverage": "~2|~4", - "satooshi/php-coveralls": "^2", "squizlabs/php_codesniffer": "^2.8" }, "suggest": { @@ -275,9 +275,36 @@ ], "type": "library", "extra": { + "scenarios": { + "symfony4": { + "require": { + "symfony/console": "^4" + }, + "config": { + "platform": { + "php": "7.1.3" + } + } + }, + "symfony2": { + "require": { + "symfony/console": "^2.8" + }, + "remove": [ + "goaop/framework" + ], + "config": { + "platform": { + "php": "5.5.9" + } + }, + "scenario-options": { + "create-lockfile": "false" + } + } + }, "branch-alias": { - "dev-master": "1.x-dev", - "dev-state": "1.x-dev" + "dev-master": "1.x-dev" } }, "autoload": { @@ -296,7 +323,7 @@ } ], "description": "Modern task runner", - "time": "2018-08-17T18:44:18+00:00" + "time": "2018-11-22T05:43:44+00:00" }, { "name": "consolidation/self-update", @@ -438,39 +465,6 @@ ], "time": "2017-01-20T21:14:22+00:00" }, - { - "name": "g1a/composer-test-scenarios", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/g1a/composer-test-scenarios.git", - "reference": "a166fd15191aceab89f30c097e694b7cf3db4880" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/g1a/composer-test-scenarios/zipball/a166fd15191aceab89f30c097e694b7cf3db4880", - "reference": "a166fd15191aceab89f30c097e694b7cf3db4880", - "shasum": "" - }, - "bin": [ - "scripts/create-scenario", - "scripts/dependency-licenses", - "scripts/install-scenario" - ], - "type": "library", - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Greg Anderson", - "email": "greg.1.anderson@greenknowe.org" - } - ], - "description": "Useful scripts for testing multiple sets of Composer dependencies.", - "time": "2018-08-08T23:37:23+00:00" - }, { "name": "grasmash/expander", "version": "1.0.0", @@ -894,16 +888,16 @@ }, { "name": "psr/log", - "version": "1.0.2", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "4ebe3a8bf773a19edfe0a84b6585ba3d401b724d" + "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/4ebe3a8bf773a19edfe0a84b6585ba3d401b724d", - "reference": "4ebe3a8bf773a19edfe0a84b6585ba3d401b724d", + "url": "https://api.github.com/repos/php-fig/log/zipball/6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd", + "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd", "shasum": "" }, "require": { @@ -937,7 +931,7 @@ "psr", "psr-3" ], - "time": "2016-10-10T12:19:37+00:00" + "time": "2018-11-20T15:27:04+00:00" }, { "name": "symfony/console",