diff --git a/CHANGELOG b/CHANGELOG index 10832aa..8e2451e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,44 @@ +Version 0.8.0 (2019-07-26) +========================== + +New features: +- Support for the Fever protocol (see README.md for details) +- Command line functionality for clearing a password, disabling the account +- Command line options for dealing with Fever passwords +- Command line functionality for importing and exporting OPML +- Command line documentation of all commands and options + +Bug fixes: +- Treat command line option -h the same as --help +- Sort Tiny Tiny RSS special feeds according to special ordering +- Invalidate sessions when passwords are changed +- Correct example systemd unit to start after PostgreSQL and MySQL + +Changes: +- Perform regular database maintenance to improve long-term performance + +Version 0.7.1 (2019-03-25) +========================== + +Bug fixes: +- Correctly initialize new on-disk SQLite databases +- Retry queries on schema changes with PDO SQLite +- Correctly read author name from database in Tiny Tiny RSS +- Update internal version number to correct version + +Changes: +- Improve performance of lesser-used database queries + +Version 0.7.0 (2019-03-02) +========================== + +New features: +- Support for basic freeform searching in Tiny Tiny RSS +- Console command to refresh all stale feeds once then exit + +Bug fixes: +- Ensure updating does not fail with newsfeeds larger than 250 entries + Version 0.6.1 (2019-01-23) ========================== diff --git a/README.md b/README.md index 2cec044..fcabd69 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ # The Advanced RSS Environment -The Arsse is a news aggregator server which implements multiple synchronization protocols, including [version 1.2][NCNv1] of [NextCloud News][NCN]' protocol and the [Tiny Tiny RSS][TTRSS] protocol (details below). Unlike most other aggregator servers, The Arsse does not include a Web front-end (though one is planned as a separate project), and it relies on existing protocols to maximize compatibility with existing clients. +The Arsse is a news aggregator server which implements multiple synchronization protocols. Unlike most other aggregator servers, The Arsse does not include a Web front-end (though one is planned as a separate project), and it relies on existing protocols to maximize compatibility with existing clients. Supported protocols are: -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: +- [NextCloud News][NCNv1] +- [Tiny Tiny RSS][TTRSS] +- [Fever][Fever] -- Providing more sync protocols (Google Reader, Fever, others) +At present the software should be considered in an "alpha" state: many features one would expect from other similar software have yet to be implemented. Areas of future work include: + +- Providing more sync protocols (Google Reader, others) - Better packaging and configuration samples - A user manual @@ -14,8 +18,8 @@ The Arsse has the following requirements: - A Linux server utilizing systemd and Nginx (tested on Ubuntu 16.04) - 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) + - [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 [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) - One of: - [sqlite3](http://php.net/manual/en/book.sqlite3.php) or [pdo_sqlite](http://php.net/manual/en/ref.pdo-sqlite.php) for SQLite databases - [pgsql](http://php.net/manual/en/book.pgsql.php) or [pdo_pgsql](http://php.net/manual/en/ref.pdo-pgsql.php) for PostgreSQL 10 or later databases @@ -48,6 +52,8 @@ The Arsse includes a `user add []` console command to add u Alternatively, if the Web server is configured to handle authentication, you may set the configuration option `userPreAuth` to `true` and The Arsse will defer to the Web server and automatically add any missing users as it encounters them. +Console commands are also available to import from and export to OPML files. Consult `php arsse.php --help` for full details. + ## Installation from source If installing from the Git repository rather than a download package, you will need to follow extra steps before the instructions in the section above. @@ -130,7 +136,6 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a - The `getPref` operation is not implemented; it returns `UNKNOWN_METHOD` - The `shareToPublished` operation is not implemented; it returns `UNKNOWN_METHOD` - Setting an article's "published" flag with the `updateArticle` operation is not implemented and will gracefully fail -- The `search` parameter of the `getHeadlines` operation is not implemented; the operation will proceed as if no search string were specified - The `sanitize`, `force_update`, and `has_sandbox` parameters of the `getHeadlines` operation are ignored - String `feed_id` values for the `getCompactHeadlines` operation are not supported and will yield an `INCORRECT_USAGE` error - Articles are limited to a single attachment rather than multiple attachments @@ -141,9 +146,14 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a - Feed, category, and label names are normally unrestricted; The Arsse rejects empty strings, as well as strings composed solely of whitespace - Discovering multiple feeds during `subscribeToFeed` processing normally produces an error; The Arsse instead chooses the first feed it finds - Providing the `setArticleLabel` operation with an invalid label normally silently fails; The Arsse returns an `INVALID_USAGE` error instead +- Processing of the `search` parameter of the `getHeadlines` operation differs in the following ways: + - Values other than `"true"` or `"false"` for the `unread`, `star`, and `pub` special keywords treat the entire token as a search term rather than as `"false"` + - Invalid dates are ignored rather than assumed to be `"1970-01-01"` + - Only a single negative date is allowed (this is a known bug rather than intentional) + - Dates are always relative to UTC + - Full-text search is not yet employed with any database, including PostgreSQL - Article hashes are normally SHA1; The Arsse uses SHA256 hashes - Article attachments normally have unique IDs; The Arsse always gives attachments an ID of `"0"` -- The default sort order of the `getHeadlines` operation normally uses custom sorting for "special" feeds; The Arsse's default sort order is equivalent to `feed_dates` for all feeds - The `getCounters` operation normally omits members with zero unread; The Arsse includes everything to appease some clients #### Other notes @@ -190,6 +200,27 @@ Tiny Tiny RSS itself is unaware of HTTP authentication: if HTTP authentication i In all cases, supplying invalid HTTP credentials will result in a 401 response. +### Fever + +Unlike other protocols thus far supported by The Arsse, a reference implementation of [the Fever protocol][Fever] is no longer available: Fever was witdrawn from sale in 2016. Consequently the Arsse's implementation may not replicate all of Fever's functionality correctly. Moreover, some features have been deliberately omitted. + +#### Special considerations + +- Because of Fever's insecure authentication protocol, a Fever-specific password must be created before a user can communicate via the Fever protocol. Consult The Arsse's online help (`php arsse.php --help`) for instructions on how to set the necessary password +- The Fever protocol does not allow for adding or modifying feeds. Another protocol or OPML importing must be used to manage feeds +- Unlike other protocols supported by The Arsse, Fever uses "groups" (more commonly known as tags or labels) instead of folders to organize feeds. Currently OPML importing is the only means of managing groups + +#### Missing features + +- All feeds are considered "Kindling" +- The "Hot Links" feature is not implemented; when requested, an empty array will be returned. As there is no way to classify a feed as a "Spark" in the protocol itself and no documentation exists on how link temperature was calculated, an implementation is unlikely to appear in the future +- Favicons are not currently supported; all feeds have a simple blank image as their favicon + +#### Other notes + +- The undocumented `group_ids`, `feed_ids`, and `as=unread` parameters are all supported +- XML output is supported, but may not behave as Fever did. JSON output is highly recommended + [newIssue]: https://code.mensbeam.com/MensBeam/arsse/issues/new [Composer]: https://getcomposer.org/ [picoFeed]: https://github.com/miniflux/picoFeed/ @@ -201,3 +232,4 @@ In all cases, supplying invalid HTTP credentials will result in a 401 response. [News+]: https://github.com/noinnion/newsplus/ [ext-feedreader]: https://github.com/jangernert/FeedReader/tree/master/data/tt-rss-feedreader-plugin [ext-newsplus]: https://github.com/hrk/tt-rss-newsplus-plugin +[Fever]: https://web.archive.org/web/20161217042229/https://feedafever.com/api diff --git a/RoboFile.php b/RoboFile.php index 104a8b7..d48e849 100644 --- a/RoboFile.php +++ b/RoboFile.php @@ -83,7 +83,7 @@ class RoboFile extends \Robo\Tasks { $dbg = dirname(\PHP_BINARY)."\\phpdbg.exe"; $dbg = file_exists($dbg) ? $dbg : ""; } else { - $dbg = `which phpdbg`; + $dbg = trim(`which phpdbg`); } if ($dbg) { return escapeshellarg($dbg)." -qrr"; @@ -96,6 +96,11 @@ class RoboFile extends \Robo\Tasks { return defined("PHP_WINDOWS_VERSION_MAJOR"); } + protected function blackhole(bool $all = false): string { + $hole = $this->isWindows() ? "nul" : "/dev/null"; + return $all ? ">$hole 2>&1" : "2>$hole"; + } + protected function runTests(string $executor, string $set, array $args) : Result { switch ($set) { case "typical": @@ -115,7 +120,7 @@ class RoboFile extends \Robo\Tasks { } $execpath = realpath(self::BASE."vendor-bin/phpunit/vendor/phpunit/phpunit/phpunit"); $confpath = realpath(self::BASE_TEST."phpunit.xml"); - $this->taskServer(8000)->host("localhost")->dir(self::BASE_TEST."docroot")->rawArg("-n")->arg(self::BASE_TEST."server.php")->background()->run(); + $this->taskServer(8000)->host("localhost")->dir(self::BASE_TEST."docroot")->rawArg("-n")->arg(self::BASE_TEST."server.php")->rawArg($this->blackhole())->background()->run(); return $this->taskExec($executor)->arg($execpath)->option("-c", $confpath)->args(array_merge($set, $args))->run(); } diff --git a/UPGRADING b/UPGRADING index a837396..c96f41c 100644 --- a/UPGRADING +++ b/UPGRADING @@ -10,6 +10,17 @@ usually prudent: - If installing from source, update dependencies with: `composer install -o --no-dev` +Upgrading from 0.7.1 to 0.8.0 +============================= + +- The database schema has changed from rev4 to rev5; if upgrading the database + manually, apply the 4.sql file +- Web server configuration has changed to accommodate Fever; the following URL + paths are affected: + - /fever/ +- The following Composer dependencies have been added: + - zendframework/zend-diactoros (version 2.x) + - zendframework/zend-httphandlerrunner Upgrading from 0.5.1 to 0.6.0 ============================= diff --git a/arsse.php b/arsse.php index 407be03..0cfa0ae 100644 --- a/arsse.php +++ b/arsse.php @@ -25,7 +25,7 @@ if (\PHP_SAPI === "cli") { $conf = file_exists(BASE."config.php") ? new Conf(BASE."config.php") : new Conf; Arsse::load($conf); // handle Web requests - $emitter = new \Zend\Diactoros\Response\SapiEmitter(); + $emitter = new \Zend\HttpHandlerRunner\Emitter\SapiEmitter; $response = (new REST)->dispatch(); $emitter->emit($response); } diff --git a/composer.json b/composer.json index 0f5570c..3fdd1c7 100644 --- a/composer.json +++ b/composer.json @@ -18,15 +18,17 @@ ], "require": { - "php": "^7.0", + "php": "7.*", "ext-intl": "*", "ext-json": "*", "ext-hash": "*", + "ext-dom": "*", "p3k/picofeed": "0.1.*", - "hosteurope/password-generator": "^1.0", - "docopt/docopt": "^1.0", - "jkingweb/druuid": "^3.0", - "zendframework/zend-diactoros": "^1.6" + "hosteurope/password-generator": "1.*", + "docopt/docopt": "1.*", + "jkingweb/druuid": "3.*", + "zendframework/zend-diactoros": "2.*", + "zendframework/zend-httphandlerrunner": "1.*" }, "require-dev": { "bamarni/composer-bin-plugin": "*" diff --git a/composer.lock b/composer.lock index b5e5d38..54a7444 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d7a6a00be3d97c11d09ec4d4e56d36e0", + "content-hash": "c2b0698669d89268ffb995a5e1d6667a", "packages": [ { "name": "docopt/docopt", @@ -190,6 +190,58 @@ "homepage": "https://github.com/miniflux/picoFeed", "time": "2017-11-30T00:16:58+00:00" }, + { + "name": "psr/http-factory", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be", + "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be", + "shasum": "" + }, + "require": { + "php": ">=7.0.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "time": "2019-04-30T12:38:16+00:00" + }, { "name": "psr/http-message", "version": "1.0.1", @@ -240,40 +292,96 @@ ], "time": "2016-08-06T14:39:51+00:00" }, + { + "name": "psr/http-server-handler", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/aff2f80e33b7f026ec96bb42f63242dc50ffcae7", + "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "time": "2018-10-30T16:46:14+00:00" + }, { "name": "zendframework/zend-diactoros", - "version": "1.8.6", + "version": "2.1.3", "source": { "type": "git", "url": "https://github.com/zendframework/zend-diactoros.git", - "reference": "20da13beba0dde8fb648be3cc19765732790f46e" + "reference": "279723778c40164bcf984a2df12ff2c6ec5e61c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/20da13beba0dde8fb648be3cc19765732790f46e", - "reference": "20da13beba0dde8fb648be3cc19765732790f46e", + "url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/279723778c40164bcf984a2df12ff2c6ec5e61c1", + "reference": "279723778c40164bcf984a2df12ff2c6ec5e61c1", "shasum": "" }, "require": { - "php": "^5.6 || ^7.0", + "php": "^7.1", + "psr/http-factory": "^1.0", "psr/http-message": "^1.0" }, "provide": { + "psr/http-factory-implementation": "1.0", "psr/http-message-implementation": "1.0" }, "require-dev": { "ext-dom": "*", "ext-libxml": "*", + "http-interop/http-factory-tests": "^0.5.0", "php-http/psr7-integration-tests": "dev-master", - "phpunit/phpunit": "^5.7.16 || ^6.0.8 || ^7.2.7", - "zendframework/zend-coding-standard": "~1.0" + "phpunit/phpunit": "^7.0.2", + "zendframework/zend-coding-standard": "~1.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.8.x-dev", - "dev-develop": "1.9.x-dev", - "dev-release-2.0": "2.0.x-dev" + "dev-master": "2.1.x-dev", + "dev-develop": "2.2.x-dev", + "dev-release-1.8": "1.8.x-dev" } }, "autoload": { @@ -293,16 +401,70 @@ }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-2-Clause" + "BSD-3-Clause" ], "description": "PSR HTTP Message implementations", - "homepage": "https://github.com/zendframework/zend-diactoros", "keywords": [ "http", "psr", "psr-7" ], - "time": "2018-09-05T19:29:37+00:00" + "time": "2019-07-10T16:13:25+00:00" + }, + { + "name": "zendframework/zend-httphandlerrunner", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/zendframework/zend-httphandlerrunner.git", + "reference": "75fb12751fe9d6e392cce1ee0d687dacae2db787" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zendframework/zend-httphandlerrunner/zipball/75fb12751fe9d6e392cce1ee0d687dacae2db787", + "reference": "75fb12751fe9d6e392cce1ee0d687dacae2db787", + "shasum": "" + }, + "require": { + "php": "^7.1", + "psr/http-message": "^1.0", + "psr/http-message-implementation": "^1.0", + "psr/http-server-handler": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.0.2", + "zendframework/zend-coding-standard": "~1.0.0", + "zendframework/zend-diactoros": "^1.7 || ^2.1.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev", + "dev-develop": "1.2.x-dev" + }, + "zf": { + "config-provider": "Zend\\HttpHandlerRunner\\ConfigProvider" + } + }, + "autoload": { + "psr-4": { + "Zend\\HttpHandlerRunner\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Execute PSR-15 RequestHandlerInterface instances and emit responses they generate.", + "keywords": [ + "ZendFramework", + "components", + "expressive", + "psr-15", + "psr-7", + "zf" + ], + "time": "2019-02-19T18:20:34+00:00" }, { "name": "zendframework/zendxml", @@ -354,16 +516,16 @@ "packages-dev": [ { "name": "bamarni/composer-bin-plugin", - "version": "v1.2.0", + "version": "v1.3.0", "source": { "type": "git", "url": "https://github.com/bamarni/composer-bin-plugin.git", - "reference": "62fef740245a85f00665e81ea8f0aa0b72afe6e7" + "reference": "67f9d314dc7ecf7245b8637906e151ccc62b8d24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/62fef740245a85f00665e81ea8f0aa0b72afe6e7", - "reference": "62fef740245a85f00665e81ea8f0aa0b72afe6e7", + "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/67f9d314dc7ecf7245b8637906e151ccc62b8d24", + "reference": "67f9d314dc7ecf7245b8637906e151ccc62b8d24", "shasum": "" }, "require": { @@ -371,7 +533,7 @@ }, "require-dev": { "composer/composer": "dev-master", - "symfony/console": "^2.5 || ^3.0" + "symfony/console": "^2.5 || ^3.0 || ^4.0" }, "type": "composer-plugin", "extra": { @@ -389,7 +551,7 @@ "license": [ "MIT" ], - "time": "2017-09-11T13:13:58+00:00" + "time": "2019-03-17T12:38:04+00:00" } ], "aliases": [], @@ -398,10 +560,11 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^7.0", + "php": "7.*", "ext-intl": "*", "ext-json": "*", - "ext-hash": "*" + "ext-hash": "*", + "ext-dom": "*" }, "platform-dev": [] } diff --git a/dist/arsse.service b/dist/arsse.service index 3e19ee8..0adcdae 100644 --- a/dist/arsse.service +++ b/dist/arsse.service @@ -1,6 +1,6 @@ [Unit] Description=The Arsse feed fetching service -After=network.target +After=network.target mysql.service postgresql.service [Service] User=www-data @@ -12,4 +12,4 @@ StandardError=syslog ExecStart=/usr/bin/env php /usr/share/arsse/arsse.php daemon [Install] -WantedBy=multi-user.target \ No newline at end of file +WantedBy=multi-user.target diff --git a/dist/nginx-fcgi.conf b/dist/nginx-fcgi.conf index fb37825..2890fc9 100644 --- a/dist/nginx-fcgi.conf +++ b/dist/nginx-fcgi.conf @@ -8,6 +8,6 @@ fastcgi_param SCRIPT_FILENAME /usr/share/arsse/arsse.php; fastcgi_param REQUEST_METHOD $request_method; fastcgi_param CONTENT_TYPE $content_type; fastcgi_param CONTENT_LENGTH $content_length; -fastcgi_param REQUEST_URI $request_uri; +fastcgi_param REQUEST_URI $uri; fastcgi_param HTTPS $https if_not_empty; -fastcgi_param REMOTE_USER $remote_user; \ No newline at end of file +fastcgi_param REMOTE_USER $remote_user; diff --git a/dist/nginx.conf b/dist/nginx.conf index c7dce50..98e130c 100644 --- a/dist/nginx.conf +++ b/dist/nginx.conf @@ -48,4 +48,9 @@ server { root /usr/share/arsse/www; try_files $uri =404; } + + # Fever protocol + location /fever/ { + try_files $uri @arsse_no_auth; + } } diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 0249678..0165d46 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -11,6 +11,7 @@ abstract class AbstractException extends \Exception { "Exception.uncoded" => -1, "Exception.unknown" => 10000, "Exception.constantUnknown" => 10001, + "Exception.arrayEmpty" => 10002, "ExceptionType.strictFailure" => 10011, "ExceptionType.typeUnknown" => 10012, "Lang/Exception.defaultFileMissing" => 10101, @@ -44,6 +45,7 @@ abstract class AbstractException extends \Exception { "Db/Exception.savepointInvalid" => 10226, "Db/Exception.savepointStale" => 10227, "Db/Exception.resultReused" => 10228, + "Db/ExceptionRetry.schemaChange" => 10229, "Db/ExceptionInput.missing" => 10231, "Db/ExceptionInput.whitespace" => 10232, "Db/ExceptionInput.tooLong" => 10233, @@ -84,6 +86,15 @@ abstract class AbstractException extends \Exception { "Feed/Exception.xmlEntity" => 10512, "Feed/Exception.subscriptionNotFound" => 10521, "Feed/Exception.unsupportedFeedFormat" => 10522, + "ImportExport/Exception.fileMissing" => 10601, + "ImportExport/Exception.fileUnreadable" => 10603, + "ImportExport/Exception.fileUnwritable" => 10604, + "ImportExport/Exception.fileUncreatable" => 10605, + "ImportExport/Exception.invalidSyntax" => 10611, + "ImportExport/Exception.invalidSemantics" => 10612, + "ImportExport/Exception.invalidFolderName" => 10613, + "ImportExport/Exception.invalidFolderCopy" => 10614, + "ImportExport/Exception.invalidTagName" => 10615, ]; public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) { diff --git a/lib/Arsse.php b/lib/Arsse.php index 7fbd1b2..82c4332 100644 --- a/lib/Arsse.php +++ b/lib/Arsse.php @@ -7,7 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse; class Arsse { - const VERSION = "0.6.1"; + const VERSION = "0.8.0"; /** @var Lang */ public static $lang; diff --git a/lib/CLI.php b/lib/CLI.php index 8ff1e1d..7c0d30b 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -6,25 +6,128 @@ declare(strict_types=1); namespace JKingWeb\Arsse; -use Docopt\Response as Opts; +use JKingWeb\Arsse\REST\Fever\User as Fever; +use JKingWeb\Arsse\ImportExport\OPML; class CLI { const USAGE = << arsse.php conf save-defaults [] arsse.php user [list] arsse.php user add [] arsse.php user remove - arsse.php user set-pass [--oldpass=] [] - arsse.php user auth + arsse.php user set-pass [] + [--oldpass=] [--fever] + arsse.php user unset-pass + [--oldpass=] [--fever] + arsse.php user auth [--fever] + arsse.php import [] + [-f | --flat] [-r | --replace] + arsse.php export [] + [-f | --flat] arsse.php --version - arsse.php --help | -h + arsse.php -h | --help -The Arsse command-line interface currently allows you to start the refresh -daemon, refresh a specific feed by numeric ID, manage users, or save default -configuration to a sample file. +The Arsse command-line interface can be used to perform various administrative +tasks such as starting the newsfeed refresh service, managing users, and +importing or exporting data. + +Commands: + + daemon + + Starts the newsfeed refreshing service, which will refresh stale feeds at + the configured interval automatically. + + feed refresh-all + + Refreshes any stale feeds once, then exits. This performs the same + function as the daemon command without looping; this is useful if use of + a scheduler such a cron is preferred over a persitent service. + + feed refresh + + Refreshes a single feed by numeric ID. This is principally for internal + use as the feed ID numbers are not usually exposed to the user. + + conf save-defaults [] + + Prints default configuration parameters to standard output, or to + if specified. Each parameter is annotated with a short description of its + purpose and usage. + + user [list] + + Prints a list of all existing users, one per line. + + user add [] + + Adds the user specified by , with the provided password + . If no password is specified, a random password will be + generated and printed to standard output. + + user remove + + Removes the user specified by . Data related to the user, + including folders and subscriptions, are immediately deleted. Feeds to + which the user was subscribed will be retained and refreshed until the + configured retention time elapses. + + user set-pass [] + + Changes 's password to . If no password is specified, + a random password will be generated and printed to standard output. + + The --oldpass= option can be used to supply a user's exiting + password if this is required by the authentication driver to change a + password. Currently this is not used by any existing driver. + + The --fever option sets a user's Fever protocol password instead of their + general password. As Fever requires that passwords be stored insecurely, + users do not have Fever passwords by default, and logging in to the Fever + protocol is disabled until a password is set. It is highly recommended + that a user's Fever password be different from their general password. + + user unset-pass + + Unsets a user's password, effectively disabling their account. As with + password setting, the --oldpass and --fever options may be used. + + user auth + + Tests logging in as with password . This only checks + that the user's password is currectly recognized; it has no side effects. + + The --fever option may be used to test the user's Fever protocol password, + if any. + + import [] + + Imports the feeds, folders, and tags found in the OPML formatted + into the account of . If no file is specified, data is instead + read from standard input. + + The --replace option interprets the OPML file as the list of all desired + feeds, folders and tags, performing any deletion or moving of existing + entries which do not appear in the flle. If this option is not specified, + the file is assumed to list desired additions only. + + The --flat option can be used to ignore any folder structures in the file, + importing any feeds only into the root folder. + + export [] + + Exports 's feeds, folders, and tags to the OPML file specified + by , or standard output if none is provided. Note that due to a + limitation of the OPML format, any commas present in tag names will not be + retained in the export. + + The --flat option can be used to omit folders from the export. Some OPML + implementations may not support folders, or arbitrary nesting; this option + may be used when planning to import into such software. USAGE_TEXT; protected function usage($prog): string { @@ -50,6 +153,12 @@ USAGE_TEXT; return true; } + protected function resolveFile($file, string $mode): string { + // TODO: checking read/write permissions on the provided path may be useful + $stdinOrStdout = in_array($mode, ["r", "r+"]) ? "php://input" : "php://output"; + return ($file === "-" ? null : $file) ?? $stdinOrStdout; + } + public function dispatch(array $argv = null) { $argv = $argv ?? $_SERVER['argv']; $argv0 = array_shift($argv); @@ -58,7 +167,13 @@ USAGE_TEXT; 'help' => false, ]); try { - switch ($this->command(["--help", "--version", "daemon", "feed refresh", "conf save-defaults", "user"], $args)) { + $cmd = $this->command(["-h", "--help", "--version", "daemon", "feed refresh", "feed refresh-all", "conf save-defaults", "user", "export", "import"], $args); + if ($cmd && !in_array($cmd, ["-h", "--help", "--version", "conf save-defaults"])) { + // only certain commands don't require configuration to be loaded + $this->loadConf(); + } + switch ($cmd) { + case "-h": case "--help": echo $this->usage($argv0).\PHP_EOL; return 0; @@ -66,19 +181,26 @@ USAGE_TEXT; echo Arsse::VERSION.\PHP_EOL; return 0; case "daemon": - $this->loadConf(); - $this->getService()->watch(true); + $this->getInstance(Service::class)->watch(true); return 0; case "feed refresh": - $this->loadConf(); return (int) !Arsse::$db->feedUpdate((int) $args[''], true); + case "feed refresh-all": + $this->getInstance(Service::class)->watch(false); + return 0; case "conf save-defaults": - $file = $args['']; - $file = ($file === "-" ? null : $file) ?? "php://output"; - return (int) !($this->getConf())->exportFile($file, true); + $file = $this->resolveFile($args[''], "w"); + return (int) !$this->getInstance(Conf::class)->exportFile($file, true); case "user": - $this->loadConf(); return $this->userManage($args); + case "export": + $u = $args['']; + $file = $this->resolveFile($args[''], "w"); + return (int) !$this->getInstance(OPML::class)->exportFile($file, $u, ($args['--flat'] || $args['-f'])); + case "import": + $u = $args['']; + $file = $this->resolveFile($args[''], "r"); + return (int) !$this->getInstance(OPML::class)->importFile($file, $u, ($args['--flat'] || $args['-f']), ($args['--replace'] || $args['-r'])); } } catch (AbstractException $e) { $this->logError($e->getMessage()); @@ -92,25 +214,36 @@ USAGE_TEXT; } /** @codeCoverageIgnore */ - protected function getService(): Service { - return new Service; - } - - /** @codeCoverageIgnore */ - protected function getConf(): Conf { - return new Conf; + protected function getInstance(string $class) { + return new $class; } protected function userManage($args): int { - switch ($this->command(["add", "remove", "set-pass", "list", "auth"], $args)) { + switch ($this->command(["add", "remove", "set-pass", "unset-pass", "list", "auth"], $args)) { case "add": return $this->userAddOrSetPassword("add", $args[""], $args[""]); case "set-pass": - return $this->userAddOrSetPassword("passwordSet", $args[""], $args[""], $args["--oldpass"]); + if ($args['--fever']) { + $passwd = $this->getInstance(Fever::class)->register($args[""], $args[""]); + if (is_null($args[""])) { + echo $passwd.\PHP_EOL; + } + return 0; + } else { + return $this->userAddOrSetPassword("passwordSet", $args[""], $args[""], $args["--oldpass"]); + } + // no break + case "unset-pass": + if ($args['--fever']) { + $this->getInstance(Fever::class)->unregister($args[""]); + } else { + Arsse::$user->passwordUnset($args[""], $args["--oldpass"]); + } + return 0; case "remove": return (int) !Arsse::$user->remove($args[""]); case "auth": - return $this->userAuthenticate($args[""], $args[""]); + return $this->userAuthenticate($args[""], $args[""], $args["--fever"]); case "list": case "": return $this->userList(); @@ -133,8 +266,9 @@ USAGE_TEXT; return 0; } - protected function userAuthenticate(string $user, string $password): int { - if (Arsse::$user->auth($user, $password)) { + protected function userAuthenticate(string $user, string $password, bool $fever = false): int { + $result = $fever ? $this->getInstance(Fever::class)->authenticate($user, $password) : Arsse::$user->auth($user, $password); + if ($result) { echo Arsse::$lang->msg("CLI.Auth.Success").\PHP_EOL; return 0; } else { diff --git a/lib/Conf.php b/lib/Conf.php index bba5821..15a8345 100644 --- a/lib/Conf.php +++ b/lib/Conf.php @@ -286,7 +286,7 @@ class Conf { } switch (self::EXPECTED_TYPES[$key] ?? gettype($this->$key)) { case "integer": - return Value::normalize($value, Value::T_INT | $mode); + return Value::normalize($value, Value::T_INT | $mode); // @codeCoverageIgnore case "double": return Value::normalize($value, Value::T_FLOAT | $mode); case "string": diff --git a/lib/Context/Context.php b/lib/Context/Context.php new file mode 100644 index 0000000..fb1236a --- /dev/null +++ b/lib/Context/Context.php @@ -0,0 +1,56 @@ +not = new ExclusionContext($this); + } + + public function __clone() { + // clone the exclusion context as well + $this->not = clone $this->not; + } + + /** @codeCoverageIgnore */ + public function __destruct() { + unset($this->not); + } + + public function limit(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function offset(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function unread(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function starred(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function labelled(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function annotated(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } +} diff --git a/lib/Misc/Context.php b/lib/Context/ExclusionContext.php similarity index 50% rename from lib/Misc/Context.php rename to lib/Context/ExclusionContext.php index 93e4ac4..e7323ea 100644 --- a/lib/Misc/Context.php +++ b/lib/Context/ExclusionContext.php @@ -4,38 +4,64 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Misc; +namespace JKingWeb\Arsse\Context; -use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; +use JKingWeb\Arsse\Misc\Date; -class Context { - public $reverse = false; - public $limit = 0; - public $offset = 0; +class ExclusionContext { public $folder; + public $folders; public $folderShallow; + public $foldersShallow; + public $tag; + public $tags; + public $tagName; + public $tagNames; public $subscription; + public $subscriptions; + public $edition; + public $editions; + public $article; + public $articles; + public $label; + public $labels; + public $labelName; + public $labelNames; + public $annotationTerms; + public $searchTerms; + public $titleTerms; + public $authorTerms; public $oldestArticle; public $latestArticle; public $oldestEdition; public $latestEdition; - public $unread = null; - public $starred = null; public $modifiedSince; public $notModifiedSince; public $markedSince; public $notMarkedSince; - public $edition; - public $article; - public $editions; - public $articles; - public $label; - public $labelName; - public $labelled = null; - public $annotated = null; protected $props = []; + protected $parent; + + public function __construct(self $c = null) { + $this->parent = $c; + } + + public function __clone() { + // if the context was cloned because its parent was cloned, change the parent to the clone + if ($this->parent) { + $t = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]; + if (($t['object'] ?? null) instanceof self && $t['function'] === "__clone") { + $this->parent = $t['object']; + } + } + } + + /** @codeCoverageIgnore */ + public function __destruct() { + unset($this->parent); + } protected function act(string $prop, int $set, $value) { if ($set) { @@ -46,127 +72,199 @@ class Context { $this->props[$prop] = true; $this->$prop = $value; } - return $this; + return $this->parent ?? $this; } else { return isset($this->props[$prop]); } } - protected function cleanArray(array $spec): array { + protected function cleanIdArray(array $spec, bool $allowZero = false): array { $spec = array_values($spec); for ($a = 0; $a < sizeof($spec); $a++) { - if (ValueInfo::id($spec[$a])) { + if (ValueInfo::id($spec[$a], $allowZero)) { $spec[$a] = (int) $spec[$a]; } else { - $spec[$a] = 0; + $spec[$a] = null; + } + } + return array_values(array_unique(array_filter($spec, function($v) { + return !is_null($v); + }))); + } + + protected function cleanStringArray(array $spec): array { + $spec = array_values($spec); + $stop = sizeof($spec); + for ($a = 0; $a < $stop; $a++) { + if (strlen($str = ValueInfo::normalize($spec[$a], ValueInfo::T_STRING | ValueInfo::M_DROP) ?? "")) { + $spec[$a] = $str; + } else { + unset($spec[$a]); } } - return array_values(array_filter($spec)); + return array_values(array_unique($spec)); } - public function reverse(bool $spec = null) { + public function folder(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function limit(int $spec = null) { + public function folders(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec, true); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function offset(int $spec = null) { + public function folderShallow(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function folder(int $spec = null) { + public function foldersShallow(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec, true); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function folderShallow(int $spec = null) { + public function tag(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function subscription(int $spec = null) { + public function tags(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function latestArticle(int $spec = null) { + public function tagName(string $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function oldestArticle(int $spec = null) { + public function tagNames(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function latestEdition(int $spec = null) { + public function subscription(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function oldestEdition(int $spec = null) { + public function subscriptions(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function unread(bool $spec = null) { + public function edition(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function starred(bool $spec = null) { + public function article(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function modifiedSince($spec = null) { - $spec = Date::normalize($spec); + public function editions(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function notModifiedSince($spec = null) { - $spec = Date::normalize($spec); + public function articles(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function markedSince($spec = null) { - $spec = Date::normalize($spec); + public function label(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function notMarkedSince($spec = null) { - $spec = Date::normalize($spec); + public function labels(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function edition(int $spec = null) { + public function labelName(string $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function article(int $spec = null) { + public function labelNames(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function editions(array $spec = null) { + public function annotationTerms(array $spec = null) { if (isset($spec)) { - $spec = $this->cleanArray($spec); + $spec = $this->cleanStringArray($spec); } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function articles(array $spec = null) { + public function searchTerms(array $spec = null) { if (isset($spec)) { - $spec = $this->cleanArray($spec); + $spec = $this->cleanStringArray($spec); } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function label(int $spec = null) { + public function titleTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function labelName(string $spec = null) { + public function authorTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function labelled(bool $spec = null) { + public function latestArticle(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function annotated(bool $spec = null) { + public function oldestArticle(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function latestEdition(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function oldestEdition(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function modifiedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function notModifiedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function markedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function notMarkedSince($spec = null) { + $spec = Date::normalize($spec); return $this->act(__FUNCTION__, func_num_args(), $spec); } } diff --git a/lib/Database.php b/lib/Database.php index c3ac4c0..366d84d 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -9,13 +9,47 @@ 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\Context\Context; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; +/** The high-level interface with the database + * + * The database stores information on the following things: + * + * - Users + * - Subscriptions to feeds, which belong to users + * - Folders, which belong to users and contain subscriptions + * - Tags, which belong to users and can be assigned to multiple subscriptions + * - Feeds to which users are subscribed + * - Articles, which belong to feeds and for which users can only affect metadata + * - Editions, identifying authorial modifications to articles + * - Labels, which belong to users and can be assigned to multiple articles + * - Sessions, used by some protocols to identify users across periods of time + * - Tokens, similar to sessions, but with more control over their properties + * - Metadata, used internally by the server + * + * The various methods of this class perform operations on these things, with + * each public method prefixed with the thing it concerns e.g. userRemove() + * deletes a user from the database, and labelArticlesSet() changes a label's + * associations with articles. There has been an effort to keep public method + * names consistent throughout, but protected methods, having different + * concerns, will typicsally follow different conventions. + */ class Database { - const SCHEMA_VERSION = 4; - const LIMIT_ARTICLES = 50; + /** The version number of the latest schema the interface is aware of */ + const SCHEMA_VERSION = 5; + /** The size of a set of values beyond which the set will be embedded into the query text */ + const LIMIT_SET_SIZE = 25; + /** The length of a string in an embedded set beyond which a parameter placeholder will be used for the string */ + const LIMIT_SET_STRING_LENGTH = 200; + /** Makes tag/label association change operations remove members */ + const ASSOC_REMOVE = 0; + /** Makes tag/label association change operations add members */ + const ASSOC_ADD = 1; + /** Makes tag/label association change operations replace members */ + const ASSOC_REPLACE = 2; + /** A map database driver short-names and their associated class names */ const DRIVER_NAMES = [ 'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class, 'postgresql' => \JKingWeb\Arsse\Db\PostgreSQL\Driver::class, @@ -25,6 +59,10 @@ class Database { /** @var Db\Driver */ public $db; + /** Constructs the database interface + * + * @param boolean $initialize Whether to attempt to upgrade the databse schema when constructing + */ public function __construct($initialize = true) { $driver = Arsse::$conf->dbDriver; $this->db = $driver::create(); @@ -34,10 +72,14 @@ class Database { } } + /** Returns the bare name of the calling context's calling method, when __FUNCTION__ is not appropriate */ protected function caller(): string { return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function']; } + /** Lists the available database drivers, as an associative array with + * fully-qualified class names as keys, and human-readable descriptions as values + */ public static function driverList(): array { $sep = \DIRECTORY_SEPARATOR; $path = __DIR__.$sep."Db".$sep; @@ -50,10 +92,12 @@ class Database { return $classes; } + /** Returns the current (actual) schema version of the database; compared against self::SCHEMA_VERSION to know when an upgrade is required */ public function driverSchemaVersion(): int { return $this->db->schemaVersion(); } + /** Attempts to update the database schema. If it is already up to date, false is returned */ public function driverSchemaUpdate(): bool { if ($this->db->schemaVersion() < self::SCHEMA_VERSION) { return $this->db->schemaUpdate(self::SCHEMA_VERSION); @@ -61,10 +105,23 @@ class Database { return false; } + /** Returns whether the database's character set is Unicode */ public function driverCharsetAcceptable(): bool { return $this->db->charsetAcceptable(); } + /** Performs maintenance on the database to ensure good performance */ + public function driverMaintenance(): bool { + return $this->db->maintenance(); + } + + /** Computes the column and value text of an SQL "SET" clause, validating arbitrary input against a whitelist + * + * Returns an indexed array containing the clause text, an array of types, and another array of values + * + * @param array $props An associative array containing untrusted data; keys are column names + * @param array $valid An associative array containing a whitelist: keys are column names, and values are strings representing data types + */ protected function generateSet(array $props, array $valid): array { $out = [ [], // query clause @@ -83,31 +140,104 @@ class Database { return $out; } + /** Computes the contents of an SQL "IN()" clause, for each input value either embedding the value or producing a parameter placeholder + * + * Returns an indexed array containing the clause text, an array of types, and an array of values. Note that the array of output values may not match the array of input values + * + * @param array $values Arbitrary values + * @param string $type A single data type applied to each value + */ protected function generateIn(array $values, string $type): array { - $out = [ - "", // query clause - [], // binding types - ]; - 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 (!sizeof($values)) { // if the set is empty, some databases require an explicit null - $out[0] = "null"; + return ["null", [], []]; + } + $t = (Statement::TYPES[$type] ?? 0) % Statement::T_NOT_NULL; + if (sizeof($values) > self::LIMIT_SET_SIZE && ($t == Statement::T_INTEGER || $t == Statement::T_STRING)) { + $clause = []; + $params = []; + $count = 0; + $convType = Db\AbstractStatement::TYPE_NORM_MAP[Statement::TYPES[$type]]; + foreach ($values as $v) { + $v = ValueInfo::normalize($v, $convType, null, "sql"); + if (is_null($v)) { + // nulls are pointless to have + continue; + } elseif (is_string($v)) { + if (strlen($v) > self::LIMIT_SET_STRING_LENGTH) { + $clause[] = "?"; + $params[] = $v; + } else { + $clause[] = $this->db->literalString($v); + } + } else { + $clause[] = ValueInfo::normalize($v, ValueInfo::T_STRING, null, "sql"); + } + $count++; + } + if (!$count) { + // the set is actually empty + return ["null", [], []]; + } else { + return [implode(",", $clause), array_fill(0, sizeof($params), $type), $params]; + } + } else { + return [implode(",", array_fill(0, sizeof($values), "?")), array_fill(0, sizeof($values), $type), $values]; } - return $out; } + /** Computes basic LIKE-based text search constraints for use in a WHERE clause + * + * Returns an indexed array containing the clause text, an array of types, and another array of values + * + * The clause is structured such that all terms must be present across any of the columns + * + * @param string[] $terms The terms to search for + * @param string[] $cols The columns to match against; these are -not- sanitized, so much -not- come directly from user input + * @param boolean $matchAny Whether the search is successful when it matches any (true) or all (false) terms + */ + protected function generateSearch(array $terms, array $cols, bool $matchAny = false): array { + if (!$cols) { + throw new Exception("arrayEmpty", "cols"); // @codeCoverageIgnore + } + $clause = []; + $types = []; + $values = []; + $like = $this->db->sqlToken("like"); + $embedSet = sizeof($terms) > ((int) (self::LIMIT_SET_SIZE / sizeof($cols))); + foreach ($terms as $term) { + $embedTerm = ($embedSet && strlen($term) <= self::LIMIT_SET_STRING_LENGTH); + $term = str_replace(["%", "_", "^"], ["^%", "^_", "^^"], $term); + $term = "%$term%"; + $term = $embedTerm ? $this->db->literalString($term) : $term; + $spec = []; + foreach ($cols as $col) { + if ($embedTerm) { + $spec[] = "$col $like $term escape '^'"; + } else { + $spec[] = "$col $like ? escape '^'"; + $types[] = "str"; + $values[] = $term; + } + } + $clause[] = "(".implode(" or ", $spec).")"; + } + $glue = $matchAny ? "or" : "and"; + $clause = $clause ? "(".implode(" $glue ", $clause).")" : ""; + return [$clause, $types, $values]; + } + + /** Returns a Transaction object, which is rolled back unless explicitly committed */ public function begin(): Db\Transaction { return $this->db->begin(); } + /** Retrieve a value from the metadata table. If the key is not set null is returned */ public function metaGet(string $key) { return $this->db->prepare("SELECT value from arsse_meta where \"key\" = ?", "str")->run($key)->getValue(); } + /** Sets the given key in the metadata table to the given value. If the key already exists it is silently overwritten */ public function metaSet(string $key, $value, string $type = "str"): bool { $out = $this->db->prepare("UPDATE arsse_meta set value = ? where \"key\" = ?", $type, "str")->run($value, $key)->changes(); if (!$out) { @@ -116,10 +246,12 @@ class Database { return (bool) $out; } + /** Unsets the given key in the metadata table. Returns false if the key does not exist */ public function metaRemove(string $key): bool { return (bool) $this->db->prepare("DELETE from arsse_meta where \"key\" = ?", "str")->run($key)->changes(); } + /** Returns whether the specified user exists in the database */ public function userExists(string $user): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -127,6 +259,11 @@ class Database { return (bool) $this->db->prepare("SELECT count(*) from arsse_users where id = ?", "str")->run($user)->getValue(); } + /** Adds a user to the database + * + * @param string $user The user to add + * @param string $passwordThe user's password in cleartext. It will be stored hashed + */ public function userAdd(string $user, string $password): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -138,6 +275,7 @@ class Database { return true; } + /** Removes a user from the database */ public function userRemove(string $user): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -148,6 +286,7 @@ class Database { return true; } + /** Returns a flat, indexed array of all users in the database */ public function userList(): array { $out = []; if (!Arsse::$user->authorize("", __FUNCTION__)) { @@ -159,26 +298,33 @@ class Database { return $out; } - public function userPasswordGet(string $user): string { + /** Retrieves the hashed password of a user */ + public function userPasswordGet(string $user) { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } elseif (!$this->userExists($user)) { throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); } - return (string) $this->db->prepare("SELECT password from arsse_users where id = ?", "str")->run($user)->getValue(); + return $this->db->prepare("SELECT password from arsse_users where id = ?", "str")->run($user)->getValue(); } - public function userPasswordSet(string $user, string $password): bool { + /** Sets the password of an existing user + * + * @param string $user The user for whom to set the password + * @param string $password The new password, in cleartext. The password will be stored hashed. If null is passed, the password is unset and authentication not possible + */ + public function userPasswordSet(string $user, string $password = null): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } elseif (!$this->userExists($user)) { throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); } - $hash = (strlen($password) > 0) ? password_hash($password, \PASSWORD_DEFAULT) : ""; + $hash = (strlen($password ?? "") > 0) ? password_hash($password, \PASSWORD_DEFAULT) : $password; $this->db->prepare("UPDATE arsse_users set password = ? where id = ?", "str", "str")->run($hash, $user); return true; } + /** Creates a new session for the given user and returns the session identifier */ public function sessionCreate(string $user): string { // If the user isn't authorized to perform this action then throw an exception. if (!Arsse::$user->authorize($user, __FUNCTION__)) { @@ -193,15 +339,33 @@ class Database { return $id; } - public function sessionDestroy(string $user, string $id): bool { + /** Explicitly removes a session from the database + * + * Sessions may also be invalidated as they expire, and then be automatically pruned. + * This function can be used to explicitly invalidate a session after a user logs out + * + * @param string $user The user who owns the session to be destroyed + * @param string|null $id The identifier of the session to destroy + */ + public function sessionDestroy(string $user, string $id = null): bool { // If the user isn't authorized to perform this action then throw an exception. if (!Arsse::$user->authorize($user, __FUNCTION__)) { 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(); + if (is_null($id)) { + // delete all sessions and report success unconditionally if no identifier was specified + $this->db->prepare("DELETE FROM arsse_sessions where \"user\" = ?", "str")->run($user); + return true; + } else { + // otherwise delete only the specified session and report success. + return (bool) $this->db->prepare("DELETE FROM arsse_sessions where id = ? and \"user\" = ?", "str", "str")->run($id, $user)->changes(); + } } + /** Resumes a session, returning available session data + * + * This also has the side effect of refreshing the session if it is near its timeout + */ 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(); @@ -217,11 +381,13 @@ class Database { return $out; } + /** Deletes expires sessions from the database, returning the number of deleted sessions */ public function sessionCleanup(): int { $maxAge = Date::sub(Arsse::$conf->userSessionLifetime); return $this->db->prepare("DELETE FROM arsse_sessions where expires < CURRENT_TIMESTAMP or created < ?", "datetime")->run($maxAge)->changes(); } + /** Checks if a given future timeout is less than half the session timeout interval */ protected function sessionExpiringSoon(\DateTimeInterface $expiry): bool { // calculate half the session timeout as a number of seconds $now = time(); @@ -231,6 +397,73 @@ class Database { return (($now + $diff) >= $expiry->getTimestamp()); } + /** Creates a new token for the given user in the given class + * + * @param string $user The user for whom to create the token + * @param string $class The class of the token e.g. the protocol name + * @param string|null $id The value of the token; if none is provided a UUID will be generated + * @param \DateTimeInterface|null $expires An optional expiry date and time for the token + */ + public function tokenCreate(string $user, string $class, string $id = null, \DateTimeInterface $expires = null): string { + // If the user isn't authorized to perform this action then throw an exception. + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } elseif (!$this->userExists($user)) { + throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); + } + // generate a token if it's not provided + $id = $id ?? UUID::mint()->hex; + // save the token to the database + $this->db->prepare("INSERT INTO arsse_tokens(id,class,\"user\",expires) values(?,?,?,?)", "str", "str", "str", "datetime")->run($id, $class, $user, $expires); + // return the ID + return $id; + } + + /** Revokes one or all tokens for a user in a class + * + * @param string $user The user who owns the token to be revoked + * @param string $class The class of the token e.g. the protocol name + * @param string|null $id The ID of a specific token, or null for all tokens in the class + */ + public function tokenRevoke(string $user, string $class, string $id = null): bool { + // If the user isn't authorized to perform this action then throw an exception. + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + if (is_null($id)) { + $out = $this->db->prepare("DELETE FROM arsse_tokens where \"user\" = ? and class = ?", "str", "str")->run($user, $class)->changes(); + } else { + $out = $this->db->prepare("DELETE FROM arsse_tokens where \"user\" = ? and class = ? and id = ?", "str", "str", "str")->run($user, $class, $id)->changes(); + } + return (bool) $out; + } + + /** Look up data associated with a token */ + public function tokenLookup(string $class, string $id): array { + $out = $this->db->prepare("SELECT id,class,\"user\",created,expires from arsse_tokens where class = ? and id = ? and expires > CURRENT_TIMESTAMP", "str", "str")->run($class, $id)->getRow(); + if (!$out) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "token", 'id' => $id]); + } + return $out; + } + + /** Deletes expires tokens from the database, returning the number of deleted tokens */ + public function tokenCleanup(): int { + return $this->db->query("DELETE FROM arsse_tokens where expires < CURRENT_TIMESTAMP")->changes(); + } + + /** Adds a folder for containing newsfeed subscriptions, returning an integer identifying the created folder + * + * The $data array may contain the following keys: + * + * - "name": A folder name, which must be a non-empty string not composed solely of whitespace; this key is required + * - "parent": An integer (or null) identifying a parent folder; this key is optional + * + * If a folder with the same name and parent already exists, this is an error + * + * @param string $user The user who will own the folder + * @param array $data An associative array defining the folder + */ public function folderAdd(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__)) { @@ -245,6 +478,20 @@ class Database { return $this->db->prepare("INSERT INTO arsse_folders(owner,parent,name) values(?,?,?)", "str", "int", "str")->run($user, $parent, $name)->lastId(); } + /** Returns a result set listing a user's folders + * + * Each record in the result set contains: + * + * - "id": The folder identifier, an integer + * - "name": The folder's name, a string + * - "parent": The integer identifier of the folder's parent, or null + * - "children": The number of child folders contained in the given folder + * - "feeds": The number of newsfeed subscriptions contained in the given folder, not including subscriptions in descendent folders + * + * @param string $uer The user whose folders are to be listed + * @param integer|null $parent Restricts the list to the descendents of the specified folder identifier + * @param boolean $recursive Whether to list all descendents (true) or only direct children (false) + */ public function folderList(string $user, $parent = null, bool $recursive = true): Db\Result { // if the user isn't authorized to perform this action then throw an exception. if (!Arsse::$user->authorize($user, __FUNCTION__)) { @@ -254,14 +501,18 @@ class Database { $parent = $this->folderValidateId($user, $parent)['id']; $q = new Query( "SELECT - id,name,parent, - (select count(*) from arsse_folders as parents where coalesce(parents.parent,0) = coalesce(arsse_folders.id,0)) as children, - (select count(*) from arsse_subscriptions where coalesce(folder,0) = coalesce(arsse_folders.id,0)) as feeds - FROM arsse_folders" + id, + name, + arsse_folders.parent as parent, + coalesce(children,0) as children, + coalesce(feeds,0) as feeds + FROM arsse_folders + left join (SELECT parent,count(id) as children from arsse_folders group by parent) as child_stats on child_stats.parent = arsse_folders.id + left join (SELECT folder,count(id) as feeds from arsse_subscriptions group by folder) as sub_stats on sub_stats.folder = arsse_folders.id" ); if (!$recursive) { $q->setWhere("owner = ?", "str", $user); - $q->setWhere("coalesce(parent,0) = ?", "strict int", $parent); + $q->setWhere("coalesce(arsse_folders.parent,0) = ?", "strict int", $parent); } else { $q->setCTE("folders", "SELECT id from arsse_folders where owner = ? and coalesce(parent,0) = ? union select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id", ["str", "strict int"], [$user, $parent]); $q->setWhere("id in (SELECT id from folders)"); @@ -270,6 +521,13 @@ class Database { return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } + /** Deletes a folder from the database + * + * Any descendent folders are also deleted, as are all newsfeed subscriptions contained in the deleted folder tree + * + * @param string $user The user to whom the folder to be deleted belongs + * @param integer $id The identifier of the folder to delete + */ public function folderRemove(string $user, $id): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -284,6 +542,7 @@ class Database { return true; } + /** Returns the identifier, name, and parent of the given folder as an associative array */ public function folderPropertiesGet(string $user, $id): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -298,6 +557,19 @@ class Database { return $props; } + /** Modifies the properties of a folder + * + * The $data array must contain one or more of the following keys: + * + * - "name": A new folder name, which must be a non-empty string not composed solely of whitespace + * - "parent": An integer (or null) identifying a parent folder + * + * If a folder with the new name and parent combination already exists, this is an error; it is also an error to move a folder to itself or one of its descendents + * + * @param string $user The user who owns the folder to be modified + * @param integer $id The identifier of the folder to be modified + * @param array $data An associative array of properties to modify. Anything not specified will remain unchanged + */ public function folderPropertiesSet(string $user, $id, array $data): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -334,6 +606,14 @@ class Database { return (bool) $this->db->prepare("UPDATE arsse_folders set $setClause, modified = CURRENT_TIMESTAMP where owner = ? and id = ?", $setTypes, "str", "int")->run($setValues, $user, $id)->changes(); } + /** Ensures the specified folder exists and raises an exception otherwise + * + * Returns an associative array containing the id, name, and parent of the folder if it exists + * + * @param string $user The user who owns the folder to be validated + * @param integer|null $id The identifier of the folder to validate; null or zero represent the implied root folder + * @param boolean $subject Whether the folder is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails + */ protected function folderValidateId(string $user, $id = null, bool $subject = false): array { // if the specified ID is not a non-negative integer (or null), this will always fail if (!ValueInfo::id($id, true)) { @@ -351,6 +631,7 @@ class Database { return $f; } + /** Ensures an operation to rename and/or move a folder does not result in a conflict or circular dependence, and raises an exception otherwise */ protected function folderValidateMove(string $user, $id = null, $parent = null, string $name = null) { $errData = ["action" => $this->caller(), "field" => "parent", 'id' => $parent]; if (!$id) { @@ -403,6 +684,12 @@ class Database { return $parent; } + /** Ensures a prospective folder name is valid, and optionally ensure it is not a duplicate if renamed + * + * @param string $name The name to check + * @param boolean $checkDuplicates Whether to also check if the new name would cause a collision + * @param integer|null $parent The parent folder context in which to check for duplication + */ protected function folderValidateName($name, bool $checkDuplicates = false, $parent = null): bool { $info = ValueInfo::str($name); if ($info & (ValueInfo::NULL | ValueInfo::EMPTY)) { @@ -424,34 +711,31 @@ class Database { } } + /** Adds a subscription to a newsfeed, and returns the numeric identifier of the added subscription + * + * @param string $user The user which will own the subscription + * @param string $url The URL of the newsfeed or discovery source + * @param string $fetchUser The user name required to access the newsfeed, if applicable + * @param string $fetchPassword The password required to fetch the newsfeed, if applicable; this will be stored in cleartext + * @param boolean $discover Whether to perform newsfeed discovery if $url points to an HTML document + */ public function subscriptionAdd(string $user, string $url, string $fetchUser = "", string $fetchPassword = "", bool $discover = true): int { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } - // check to see if the feed exists - $check = $this->db->prepare("SELECT id from arsse_feeds where url = ? and username = ? and password = ?", "str", "str", "str"); - $feedID = $check->run($url, $fetchUser, $fetchPassword)->getValue(); - if ($discover && is_null($feedID)) { - // if the feed doesn't exist, first perform discovery if requested and check for the existence of that URL - $url = Feed::discover($url, $fetchUser, $fetchPassword); - $feedID = $check->run($url, $fetchUser, $fetchPassword)->getValue(); - } - if (is_null($feedID)) { - // if the feed still doesn't exist in the database, add it to the database; we do this unconditionally so as to lock SQLite databases for as little time as possible - $feedID = $this->db->prepare('INSERT INTO arsse_feeds(url,username,password) values(?,?,?)', 'str', 'str', 'str')->run($url, $fetchUser, $fetchPassword)->lastId(); - try { - // perform an initial update on the newly added feed - $this->feedUpdate($feedID, true); - } catch (\Throwable $e) { - // if the update fails, delete the feed we just added - $this->db->prepare('DELETE from arsse_feeds where id = ?', 'int')->run($feedID); - throw $e; - } - } + // get the ID of the underlying feed, or add it if it's not yet in the database + $feedID = $this->feedAdd($url, $fetchUser, $fetchPassword, $discover); // Add the feed to the user's subscriptions and return the new subscription's ID. return $this->db->prepare('INSERT INTO arsse_subscriptions(owner,feed) values(?,?)', 'str', 'int')->run($user, $feedID)->lastId(); } + /** Lists a user's subscriptions, returning various data + * + * @param string $user The user whose subscriptions are to be listed + * @param integer|null $folder The identifier of the folder under which to list subscriptions; by default the root folder is used + * @param boolean $recursive Whether to list subscriptions of descendent folders as well as the selected folder + * @param integer|null $id The numeric identifier of a particular subscription; used internally by subscriptionPropertiesGet + */ public function subscriptionList(string $user, $folder = null, bool $recursive = true, int $id = null): Db\Result { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -462,22 +746,25 @@ class Database { $q = new Query( "SELECT arsse_subscriptions.id as id, - feed,url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, + arsse_subscriptions.feed as feed, + url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, arsse_feeds.updated as updated, + arsse_feeds.modified as edited, + arsse_subscriptions.modified as modified, topmost.top as top_folder, 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 userdata on userid = owner - join arsse_feeds on feed = arsse_feeds.id - left join topmost on folder=f_id" + (articles - marked) as unread + FROM arsse_subscriptions + left join topmost on topmost.f_id = arsse_subscriptions.folder + join arsse_feeds on arsse_feeds.id = arsse_subscriptions.feed + left join (select feed, count(*) as articles from arsse_articles group by feed) as article_stats on article_stats.feed = arsse_subscriptions.feed + left join (select subscription, sum(\"read\") as marked from arsse_marks group by subscription) as mark_stats on mark_stats.subscription = arsse_subscriptions.id" ); + $q->setWhere("arsse_subscriptions.owner = ?", ["str"], [$user]); $nocase = $this->db->sqlToken("nocase"); $q->setOrder("pinned desc, coalesce(arsse_subscriptions.title, arsse_feeds.title) collate $nocase"); - // define common table expressions - $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 userdata on owner = userid 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 where owner = ? and parent is null union select id,top from arsse_folders join topmost on parent=f_id", ["str"], [$user]); 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 @@ -494,6 +781,7 @@ class Database { return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } + /** Returns the number of subscriptions in a folder, counting recursively */ public function subscriptionCount(string $user, $folder = null): int { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -512,6 +800,13 @@ class Database { return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } + /** Deletes a subscription from the database + * + * This has the side effect of deleting all marks the user has set on articles + * belonging to the newsfeed, but may not delete the articles themselves, as + * other users may also be subscribed to the same newsfeed. There is also a + * configurable retention period for newsfeeds + */ public function subscriptionRemove(string $user, $id): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -526,6 +821,24 @@ class Database { return true; } + /** Retrieves data about a particular subscription, as an associative array with the following keys: + * + * - "id": The numeric identifier of the subscription + * - "feed": The numeric identifier of the underlying newsfeed + * - "url": The URL of the newsfeed, after discovery and HTTP redirects + * - "title": The title of the newsfeed + * - "favicon": The URL of an icon representing the newsfeed or its source + * - "source": The URL of the source of the newsfeed i.e. its parent Web site + * - "folder": The numeric identifier (or null) of the subscription's folder + * - "top_folder": The numeric identifier (or null) of the top-level folder for the subscription + * - "pinned": Whether the subscription is pinned + * - "err_count": The count of times attempting to refresh the newsfeed has resulted in an error since the last successful retrieval + * - "err_msg": The error message of the last unsuccessful retrieval + * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0) + * - "added": The date and time at which the subscription was added + * - "updated": The date and time at which the newsfeed was last updated (not when it was last refreshed) + * - "unread": The number of unread articles associated with the subscription + */ public function subscriptionPropertiesGet(string $user, $id): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -540,6 +853,19 @@ class Database { return $sub; } + /** Modifies the properties of a subscription + * + * The $data array must contain one or more of the following keys: + * + * - "title": The title of the newsfeed + * - "folder": The numeric identifier (or null) of the subscription's folder + * - "pinned": Whether the subscription is pinned + * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0) + * + * @param string $user The user whose subscription is to be modified + * @param integer $id the numeric identifier of the subscription to modfify + * @param array $data An associative array of properties to modify; any keys not specified will be left unchanged + */ public function subscriptionPropertiesSet(string $user, $id, array $data): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -580,6 +906,34 @@ class Database { return $out; } + /** Returns an indexed array listing the tags assigned to a subscription + * + * @param string $user The user whose tags are to be listed + * @param integer $id The numeric identifier of the subscription whose tags are to be listed + * @param boolean $byName Whether to return the tag names (true) instead of the numeric tag identifiers (false) + */ + public function subscriptionTagsGet(string $user, $id, bool $byName = false): array { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $this->subscriptionValidateId($user, $id, true); + $field = !$byName ? "id" : "name"; + $out = $this->db->prepare("SELECT $field from arsse_tags where id in (select tag from arsse_tag_members where subscription = ? and assigned = 1) order by $field", "int")->run($id)->getAll(); + return $out ? array_column($out, $field) : []; + } + + /** Retrieves the URL of the icon for a subscription. + * + * Note that while the $user parameter is optional, it + * is NOT recommended to omit it, as this can lead to + * leaks of private information. The parameter is only + * optional because this is required for Tiny Tiny RSS, + * the original implementation of which leaks private + * information due to a design flaw. + * + * @param integer $id The numeric identifier of the subscription + * @param string|null $user The user who owns the subscription being queried + */ public function subscriptionFavicon(int $id, string $user = null): string { $q = new Query("SELECT favicon from arsse_feeds join arsse_subscriptions on feed = arsse_feeds.id"); $q->setWhere("arsse_subscriptions.id = ?", "int", $id); @@ -592,6 +946,31 @@ class Database { return (string) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } + /** Returns the time at which any of a user's subscriptions (or a specific subscription) was last refreshed, as a DateTimeImmutable object */ + public function subscriptionRefreshed(string $user, int $id = null) { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $q = new Query("SELECT max(arsse_feeds.updated) from arsse_feeds join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id"); + $q->setWhere("arsse_subscriptions.owner = ?", "str", $user); + if ($id) { + $q->setWhere("arsse_subscriptions.id = ?", "int", $id); + } + $out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); + if (!$out && $id) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id]); + } + return ValueInfo::normalize($out, ValueInfo::T_DATE | ValueInfo::M_NULL, "sql"); + } + + /** Ensures the specified subscription exists and raises an exception otherwise + * + * Returns an associative array containing the id of the subscription and the id of the underlying newsfeed + * + * @param string $user The user who owns the subscription to be validated + * @param integer $id The identifier of the subscription to validate + * @param boolean $subject Whether the subscription is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails + */ protected function subscriptionValidateId(string $user, $id, bool $subject = false): array { if (!ValueInfo::id($id)) { throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "feed", 'type' => "int > 0"]); @@ -603,11 +982,50 @@ class Database { return $out; } + /** Adds a newsfeed to the database without adding any subscriptions, and returns the numeric identifier of the added feed + * + * If the feed already exists in the database, the existing ID is returned + * + * @param string $url The URL of the newsfeed or discovery source + * @param string $fetchUser The user name required to access the newsfeed, if applicable + * @param string $fetchPassword The password required to fetch the newsfeed, if applicable; this will be stored in cleartext + * @param boolean $discover Whether to perform newsfeed discovery if $url points to an HTML document + */ + public function feedAdd(string $url, string $fetchUser = "", string $fetchPassword = "", bool $discover = true): int { + // check to see if the feed already exists + $check = $this->db->prepare("SELECT id from arsse_feeds where url = ? and username = ? and password = ?", "str", "str", "str"); + $feedID = $check->run($url, $fetchUser, $fetchPassword)->getValue(); + if ($discover && is_null($feedID)) { + // if the feed doesn't exist, first perform discovery if requested and check for the existence of that URL + $url = Feed::discover($url, $fetchUser, $fetchPassword); + $feedID = $check->run($url, $fetchUser, $fetchPassword)->getValue(); + } + if (is_null($feedID)) { + // if the feed still doesn't exist in the database, add it to the database; we do this unconditionally so as to lock SQLite databases for as little time as possible + $feedID = $this->db->prepare('INSERT INTO arsse_feeds(url,username,password) values(?,?,?)', 'str', 'str', 'str')->run($url, $fetchUser, $fetchPassword)->lastId(); + try { + // perform an initial update on the newly added feed + $this->feedUpdate($feedID, true); + } catch (\Throwable $e) { + // if the update fails, delete the feed we just added + $this->db->prepare('DELETE from arsse_feeds where id = ?', 'int')->run($feedID); + throw $e; + } + } + return (int) $feedID; + } + + /** Returns an indexed array of numeric identifiers for newsfeeds which should be refreshed */ public function feedListStale(): array { $feeds = $this->db->query("SELECT id from arsse_feeds where next_fetch <= CURRENT_TIMESTAMP")->getAll(); return array_column($feeds, 'id'); } + /** Attempts to refresh a newsfeed, returning an indication of success + * + * @param integer $feedID The numerical identifier of the newsfeed to refresh + * @param boolean $throwError Whether to throw an exception on failure in addition to storing error information in the database + */ public function feedUpdate($feedID, bool $throwError = false): bool { // check to make sure the feed exists if (!ValueInfo::id($feedID)) { @@ -759,12 +1177,16 @@ class Database { return true; } + /** Deletes orphaned newsfeeds from the database + * + * Newsfeeds are orphaned if no users are subscribed to them. Deleting a newsfeed also deletes its articles + */ public function feedCleanup(): bool { $tr = $this->begin(); // first unmark any feeds which are no longer orphaned - $this->db->query("UPDATE arsse_feeds set orphaned = null where exists(SELECT id from arsse_subscriptions where feed = arsse_feeds.id)"); + $this->db->query("WITH active_feeds as (select id from arsse_feeds left join (select feed, count(id) as count from arsse_subscriptions group by feed) as sub_stats on sub_stats.feed = arsse_feeds.id where orphaned is not null and count is not null) UPDATE arsse_feeds set orphaned = null where id in (select id from active_feeds)"); // next mark any newly orphaned feeds with the current date and time - $this->db->query("UPDATE arsse_feeds set orphaned = CURRENT_TIMESTAMP where orphaned is null and not exists(SELECT id from arsse_subscriptions where feed = arsse_feeds.id)"); + $this->db->query("WITH orphaned_feeds as (select id from arsse_feeds left join (select feed, count(id) as count from arsse_subscriptions group by feed) as sub_stats on sub_stats.feed = arsse_feeds.id where orphaned is null and count is null) UPDATE arsse_feeds set orphaned = CURRENT_TIMESTAMP where id in (select id from orphaned_feeds)"); // finally delete feeds that have been orphaned longer than the retention period, if a a purge threshold has been specified if (Arsse::$conf->purgeFeeds) { $limit = Date::sub(Arsse::$conf->purgeFeeds); @@ -776,6 +1198,18 @@ class Database { return $out; } + /** Retrieves various identifiers for the latest $count articles in the given newsfeed. The identifiers are: + * + * - "id": The database record key for the article + * - "guid": The (theoretically) unique identifier for the article + * - "edited": The time at which the article was last edited, per the newsfeed + * - "url_title_hash": A cryptographic hash of the article URL and its title + * - "url_content_hash": A cryptographic hash of the article URL and its content + * - "title_content_hash": A cryptographic hash of the article title and its content + * + * @param integer $feedID The numeric identifier of the feed + * @param integer $count The number of records to return + */ public function feedMatchLatest(int $feedID, int $count): Db\Result { return $this->db->prepare( "SELECT id, edited, guid, url_title_hash, url_content_hash, title_content_hash FROM arsse_articles WHERE feed = ? ORDER BY modified desc, id desc limit ?", @@ -784,12 +1218,27 @@ class Database { )->run($feedID, $count); } + /** Retrieves various identifiers for articles in the given newsfeed which match the input identifiers. The output identifiers are: + * + * - "id": The database record key for the article + * - "guid": The (theoretically) unique identifier for the article + * - "edited": The time at which the article was last edited, per the newsfeed + * - "url_title_hash": A cryptographic hash of the article URL and its title + * - "url_content_hash": A cryptographic hash of the article URL and its content + * - "title_content_hash": A cryptographic hash of the article title and its content + * + * @param integer $feedID The numeric identifier of the feed + * @param array $ids An array of GUIDs of articles + * @param array $hashesUT An array of hashes of articles' URL and title + * @param array $hashesUC An array of hashes of articles' URL and content + * @param array $hashesTC An array of hashes of articles' title and content + */ public function feedMatchIds(int $feedID, array $ids = [], array $hashesUT = [], array $hashesUC = [], array $hashesTC = []): Db\Result { // compile SQL IN() clauses and necessary type bindings for the four identifier lists - list($cId, $tId) = $this->generateIn($ids, "str"); - list($cHashUT, $tHashUT) = $this->generateIn($hashesUT, "str"); - list($cHashUC, $tHashUC) = $this->generateIn($hashesUC, "str"); - list($cHashTC, $tHashTC) = $this->generateIn($hashesTC, "str"); + list($cId, $tId, $vId) = $this->generateIn($ids, "str"); + list($cHashUT, $tHashUT, $vHashUT) = $this->generateIn($hashesUT, "str"); + list($cHashUC, $tHashUC, $vHashUC) = $this->generateIn($hashesUC, "str"); + list($cHashTC, $tHashTC, $vHashTC) = $this->generateIn($hashesTC, "str"); // perform the query return $articles = $this->db->prepare( "SELECT id, edited, guid, url_title_hash, url_content_hash, title_content_hash FROM arsse_articles WHERE feed = ? and (guid in($cId) or url_title_hash in($cHashUT) or url_content_hash in($cHashUC) or title_content_hash in($cHashTC))", @@ -798,13 +1247,16 @@ class Database { $tHashUT, $tHashUC, $tHashTC - )->run($feedID, $ids, $hashesUT, $hashesUC, $hashesTC); + )->run($feedID, $vId, $vHashUT, $vHashUC, $vHashTC); } - protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query { + /** Returns an associative array of result column names and their SQL computations for article queries + * + * This is used for whitelisting and defining both output column and order-by columns, as well as for resolution of some context options + */ + protected function articleColumns(): array { $greatest = $this->db->sqlToken("greatest"); - // prepare the output column list - $colDefs = [ + return [ 'id' => "arsse_articles.id", 'edition' => "latest_editions.edition", 'url' => "arsse_articles.url", @@ -813,6 +1265,7 @@ class Database { 'content' => "arsse_articles.content", 'guid' => "arsse_articles.guid", 'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash", + 'folder' => "coalesce(arsse_subscriptions.folder,0)", 'subscription' => "arsse_subscriptions.id", 'feed' => "arsse_subscriptions.feed", 'starred' => "coalesce(arsse_marks.starred,0)", @@ -821,215 +1274,365 @@ class Database { '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'))", + 'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(label_stats.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", - ]; + } + + /** Computes an SQL query to find and retrieve data about articles in the database + * + * If an empty column list is supplied, a count of articles matching the context is queried instead + * + * @param string $user The user whose articles are to be queried + * @param Context $context The search context + * @param array $cols The columns to request in the result set + */ + protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query { + // validate input + if ($context->subscription()) { + $this->subscriptionValidateId($user, $context->subscription); + } + if ($context->folder()) { + $this->folderValidateId($user, $context->folder); + } + if ($context->folderShallow()) { + $this->folderValidateId($user, $context->folderShallow); + } + if ($context->edition()) { + $this->articleValidateEdition($user, $context->edition); + } + if ($context->article()) { + $this->articleValidateId($user, $context->article); + } + if ($context->label()) { + $this->labelValidateId($user, $context->label, false); + } + if ($context->labelName()) { + $this->labelValidateId($user, $context->labelName, true); + } + // prepare the output column list; the column definitions are also used later + $colDefs = $this->articleColumns(); if (!$cols) { - // if no columns are specified return a count - $columns = "count(distinct arsse_articles.id) as count"; + // if no columns are specified return a count; don't borther with sorting + $outColumns = "count(distinct arsse_articles.id) as count"; } else { - $columns = []; + // normalize requested output and sorting columns + $norm = function($v) { + return trim(strtolower(ValueInfo::normalize($v, ValueInfo::T_STRING))); + }; + $cols = array_map($norm, $cols); + // make an output column list + $outColumns = []; foreach ($cols as $col) { - $col = trim(strtolower($col)); if (!isset($colDefs[$col])) { continue; } - $columns[] = $colDefs[$col]." as ".$col; + $outColumns[] = $colDefs[$col]." as ".$col; } - $columns = implode(",", $columns); + $outColumns = implode(",", $outColumns); } // define the basic query, to which we add lots of stuff where necessary $q = new Query( "SELECT - $columns + $outColumns 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] + join ( + SELECT article, max(id) as edition from arsse_editions group by article + ) as latest_editions on arsse_articles.id = latest_editions.article + left join ( + SELECT arsse_label_members.article, max(arsse_label_members.modified) as modified, sum(arsse_label_members.assigned) as assigned from arsse_label_members join arsse_labels on arsse_labels.id = arsse_label_members.label where arsse_labels.owner = ? group by arsse_label_members.article + ) as label_stats on label_stats.article = arsse_articles.id", + ["str", "str"], + [$user, $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); - if ($context->subscription()) { - // if a subscription is specified, make sure it exists - $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); - // 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, filter for that folder only - $q->setWhere("coalesce(arsse_subscriptions.folder,0) = ?", "int", $context->folderShallow); - } - if ($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, 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, 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->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) { - throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->articles) > self::LIMIT_ARTICLES) { - throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore + // handle the simple context options + $options = [ + // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an option to pair with for BETWEEN evaluation + "edition" => ["edition", "=", "int", ""], + "editions" => ["edition", "in", "int", ""], + "article" => ["id", "=", "int", ""], + "articles" => ["id", "in", "int", ""], + "oldestArticle" => ["id", ">=", "int", "latestArticle"], + "latestArticle" => ["id", "<=", "int", "oldestArticle"], + "oldestEdition" => ["edition", ">=", "int", "latestEdition"], + "latestEdition" => ["edition", "<=", "int", "oldestEdition"], + "modifiedSince" => ["modified_date", ">=", "datetime", "notModifiedSince"], + "notModifiedSince" => ["modified_date", "<=", "datetime", "modifiedSince"], + "markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince"], + "notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince"], + "folderShallow" => ["folder", "=", "int", ""], + "foldersShallow" => ["folder", "in", "int", ""], + "subscription" => ["subscription", "=", "int", ""], + "subscriptions" => ["subscription", "in", "int", ""], + "unread" => ["unread", "=", "bool", ""], + "starred" => ["starred", "=", "bool", ""], + ]; + foreach ($options as $m => list($col, $op, $type, $pair)) { + if (!$context->$m()) { + // context is not being used + continue; + } elseif (is_array($context->$m)) { + // context option is an array of values + if (!$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } + list($clause, $types, $values) = $this->generateIn($context->$m, $type); + $q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values); + } elseif ($pair && $context->$pair()) { + // option is paired with another which is also being used + if ($op === ">=") { + $q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->$m, $context->$pair]); + } else { + // option has already been paired + continue; + } + } else { + $q->setWhere("{$colDefs[$col]} $op ?", $type, $context->$m); } - list($inParams, $inTypes) = $this->generateIn($context->articles, "int"); - $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) - $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()) { - $id = $this->labelValidateId($user, $context->label, false)['id']; + // further handle exclusionary options if specified + foreach ($options as $m => list($col, $op, $type, $pair)) { + if (!method_exists($context->not, $m) || !$context->not->$m()) { + // context option is not being used + continue; + } elseif (is_array($context->not->$m)) { + if (!$context->not->$m) { + // for exclusions we don't care if the array is empty + continue; + } + list($clause, $types, $values) = $this->generateIn($context->not->$m, $type); + $q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values); + } elseif ($pair && $context->not->$pair()) { + // option is paired with another which is also being used + if ($op === ">=") { + $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->not->$m, $context->not->$pair]); + } else { + // option has already been paired + continue; + } } else { - $id = $this->labelValidateId($user, $context->labelName, true)['id']; + $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m); } - $q->setWhere("arsse_labels.id = ?", "int", $id); - } - // filter based on article or edition offset - if ($context->oldestArticle()) { - $q->setWhere("arsse_articles.id >= ?", "int", $context->oldestArticle); - } - if ($context->latestArticle()) { - $q->setWhere("arsse_articles.id <= ?", "int", $context->latestArticle); - } - if ($context->oldestEdition()) { - $q->setWhere("latest_editions.edition >= ?", "int", $context->oldestEdition); - } - if ($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("arsse_articles.modified >= ?", "datetime", $context->modifiedSince); } - if ($context->notModifiedSince()) { - $q->setWhere("arsse_articles.modified <= ?", "datetime", $context->notModifiedSince); - } - if ($context->markedSince()) { - $q->setWhere($colDefs['marked_date']." >= ?", "datetime", $context->markedSince); - } - if ($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("coalesce(arsse_marks.read,0) = ?", "bool", !$context->unread); - } - if ($context->starred()) { - $q->setWhere("coalesce(arsse_marks.starred,0) = ?", "bool", $context->starred); + // handle labels and tags + $options = [ + 'label' => [ + 'match_col' => "arsse_articles.id", + 'cte_name' => "labelled", + 'cte_cols' => ["article", "label_id", "label_name"], + 'cte_body' => "SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1", + 'cte_types' => ["str"], + 'cte_values' => [$user], + 'options' => [ + 'label' => ['use_name' => false, 'multi' => false], + 'labels' => ['use_name' => false, 'multi' => true], + 'labelName' => ['use_name' => true, 'multi' => false], + 'labelNames' => ['use_name' => true, 'multi' => true], + ], + ], + 'tag' => [ + 'match_col' => "arsse_subscriptions.id", + 'cte_name' => "tagged", + 'cte_cols' => ["subscription", "tag_id", "tag_name"], + 'cte_body' => "SELECT m.subscription, t.id, t.name from arsse_tag_members as m join arsse_tags as t on t.id = m.tag where t.owner = ? and m.assigned = 1", + 'cte_types' => ["str"], + 'cte_values' => [$user], + 'options' => [ + 'tag' => ['use_name' => false, 'multi' => false], + 'tags' => ['use_name' => false, 'multi' => true], + 'tagName' => ['use_name' => true, 'multi' => false], + 'tagNames' => ['use_name' => true, 'multi' => true], + ], + ], + ]; + foreach ($options as $opt) { + $seen = false; + $match = $opt['match_col']; + $table = $opt['cte_name']; + foreach ($opt['options'] as $m => $props) { + $named = $props['use_name']; + $multi = $props['multi']; + $selection = $opt['cte_cols'][0]; + $col = $opt['cte_cols'][$named ? 2 : 1]; + if ($context->$m()) { + $seen = true; + if (!$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } + if ($multi) { + list($test, $types, $values) = $this->generateIn($context->$m, $named ? "str" : "int"); + $test = "in ($test)"; + } else { + $test = "= ?"; + $types = $named ? "str" : "int"; + $values = $context->$m; + } + $q->setWhere("$match in (select $selection from $table where $col $test)", $types, $values); + } + if ($context->not->$m()) { + $seen = true; + if ($multi) { + list($test, $types, $values) = $this->generateIn($context->not->$m, $named ? "str" : "int"); + $test = "in ($test)"; + } else { + $test = "= ?"; + $types = $named ? "str" : "int"; + $values = $context->not->$m; + } + $q->setWhereNot("$match in (select $selection from $table where $col $test)", $types, $values); + } + } + if ($seen) { + $spec = $opt['cte_name']."(".implode(",", $opt['cte_cols']).")"; + $q->setCTE($spec, $opt['cte_body'], $opt['cte_types'], $opt['cte_values']); + } } - // filter based on whether the article has a note + // handle complex context options if ($context->annotated()) { $comp = ($context->annotated) ? "<>" : "="; $q->setWhere("coalesce(arsse_marks.note,'') $comp ''"); } - // return the query - return $q; - } - - protected function contextChunk(Context $context): array { - $exception = ""; - if ($context->editions()) { - // editions take precedence over articles - if (sizeof($context->editions) > self::LIMIT_ARTICLES) { - $exception = "editions"; - } - } elseif ($context->articles()) { - if (sizeof($context->articles) > self::LIMIT_ARTICLES) { - $exception = "articles"; + if ($context->labelled()) { + // any label (true) or no label (false) + $op = $context->labelled ? ">" : "="; + $q->setWhere("coalesce(label_stats.assigned,0) $op 0"); + } + if ($context->folder()) { + // add a common table expression to list the folder 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 coalesce(parent,0) = folder", "int", $context->folder); + // limit subscriptions to the listed folders + $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders)"); + } + if ($context->folders()) { + list($inClause, $inTypes, $inValues) = $this->generateIn($context->folders, "int"); + // add a common table expression to list the folders and their children so that we select from the entire subtree + $q->setCTE("folders_multi(folder)", "SELECT id as folder from (select id from (select 0 as id union select id from arsse_folders where owner = ?) as f where id in ($inClause)) as folders_multi union select id from arsse_folders join folders_multi on coalesce(parent,0) = folder", ["str", $inTypes], [$user, $inValues]); + // limit subscriptions to the listed folders + $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi)"); + } + if ($context->not->folder()) { + // add a common table expression to list the folder and its children so that we exclude from the entire subtree + $q->setCTE("folders_excluded(folder)", "SELECT ? union select id from arsse_folders join folders_excluded on coalesce(parent,0) = folder", "int", $context->not->folder); + // excluded any subscriptions in the listed folders + $q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_excluded)"); + } + if ($context->not->folders()) { + list($inClause, $inTypes, $inValues) = $this->generateIn($context->not->folders, "int"); + // add a common table expression to list the folders and their children so that we select from the entire subtree + $q->setCTE("folders_multi_excluded(folder)", "SELECT id as folder from (select id from (select 0 as id union select id from arsse_folders where owner = ?) as f where id in ($inClause)) as folders_multi_excluded union select id from arsse_folders join folders_multi_excluded on coalesce(parent,0) = folder", ["str", $inTypes], [$user, $inValues]); + // limit subscriptions to the listed folders + $q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi_excluded)"); + } + // handle text-matching context options + $options = [ + "titleTerms" => ["arsse_articles.title"], + "searchTerms" => ["arsse_articles.title", "arsse_articles.content"], + "authorTerms" => ["arsse_articles.author"], + "annotationTerms" => ["arsse_marks.note"], + ]; + foreach ($options as $m => $columns) { + if (!$context->$m()) { + continue; + } elseif (!$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element } + $q->setWhere(...$this->generateSearch($context->$m, $columns)); } - if ($exception) { - $out = []; - $list = array_chunk($context->$exception, self::LIMIT_ARTICLES); - foreach ($list as $chunk) { - $out[] = (clone $context)->$exception($chunk); + // further handle exclusionary text-matching context options + foreach ($options as $m => $columns) { + if (!$context->not->$m() || !$context->not->$m) { + continue; } - return $out; - } else { - return []; + $q->setWhereNot(...$this->generateSearch($context->not->$m, $columns, true)); } + // return the query + return $q; } - public function articleList(string $user, Context $context = null, array $fields = ["id"]): Db\Result { + /** Lists articles in the database which match a given query context + * + * If an empty column list is supplied, a count of articles is returned instead + * + * @param string $user The user whose articles are to be listed + * @param Context $context The search context + * @param array $fieldss The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type + * @param array $sort The columns to sort the result by eg. "edition desc" in decreasing order of importance + */ + public function articleList(string $user, Context $context = null, array $fields = ["id"], array $sort = []): Db\Result { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } + // make a base query based on context and output columns $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->contextChunk($context)) { - $out = []; - $tr = $this->begin(); - foreach ($contexts as $context) { - $out[] = $this->articleList($user, $context, $fields); + $q = $this->articleQuery($user, $context, $fields); + // make an ORDER BY column list + $colDefs = $this->articleColumns(); + // normalize requested output and sorting columns + $norm = function($v) { + return trim(strtolower((string) $v)); + }; + $fields = array_map($norm, $fields); + $sort = array_map($norm, $sort); + foreach ($sort as $spec) { + $col = explode(" ", $spec, 2); + $order = $col[1] ?? ""; + $col = $col[0]; + if ($order === "desc") { + $order = " desc"; + } elseif ($order === "asc" || $order === "") { + $order = ""; + } else { + // column direction spec is bogus + continue; + } + if (!isset($colDefs[$col])) { + // column name spec is bogus + continue; + } elseif (in_array($col, $fields)) { + // if the sort column is also an output column, use it as-is + $q->setOrder($col.$order); + } else { + // otherwise if the column name is valid, use its expression + $q->setOrder($colDefs[$col].$order); } - $tr->commit(); - return new Db\ResultAggregate(...$out); - } else { - $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()); } + // perform the query and return results + return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } + /** Returns a count of articles which match the given query context + * + * @param string $user The user whose articles are to be counted + * @param Context $context The search context + */ public function articleCount(string $user, Context $context = null): int { 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->contextChunk($context)) { - $out = 0; - $tr = $this->begin(); - foreach ($contexts as $context) { - $out += $this->articleCount($user, $context); - } - $tr->commit(); - return $out; - } else { - $q = $this->articleQuery($user, $context, []); - return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); - } + $q = $this->articleQuery($user, $context, []); + return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } + /** Applies one or multiple modifications to all articles matching the given query context + * + * The $data array enumerates the modifications to perform and must contain one or more of the following keys: + * + * - "read": Whether the article should be marked as read (true) or unread (false) + * - "starred": Whether the article should (true) or should not (false) be marked as starred/favourite + * - "note": A string containing a freeform plain-text note for the article + * + * @param string $user The user who owns the articles to be modified + * @param array $data An associative array of properties to modify. Anything not specified will remain unchanged + * @param Context $context The query context to match articles against + */ public function articleMark(string $user, array $data, Context $context = null): int { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1043,82 +1646,79 @@ class Database { 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->contextChunk($context)) { - $out = 0; - $tr = $this->begin(); - foreach ($contexts as $context) { - $out += $this->articleMark($user, $data, $context); - } - $tr->commit(); - return $out; - } else { - $tr = $this->begin(); - $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", "note"]); - $q->setWhere("arsse_marks.starred is null"); // null means there is no marks row for the article - $this->db->prepare("INSERT INTO arsse_marks(article,subscription,note) ".$q->getQuery(), $q->getTypes())->run($q->getValues()); + $tr = $this->begin(); + $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", "note"]); + $q->setWhere("arsse_marks.starred is null"); // null means there is no marks row for the article + $this->db->prepare("INSERT INTO arsse_marks(article,subscription,note) ".$q->getQuery(), $q->getTypes())->run($q->getValues()); + } + 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); } - 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 + // 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.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']); + $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)"); - $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']); + $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); } - // 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; - } + 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(); } - $tr->commit(); - return $out; + $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(); } + $tr->commit(); + return $out; } + /** Returns statistics about the articles starred by the given user + * + * The associative array returned has the following keys: + * + * - "total": The count of all starred articles + * - "unread": The count of starred articles which are unread + * - "read": The count of starred articles which are read + */ public function articleStarred(string $user): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1135,18 +1735,23 @@ class Database { )->run($user)->getRow(); } + /** Returns an indexed array listing the labels assigned to an article + * + * @param string $user The user whose labels are to be listed + * @param integer $id The numeric identifier of the article whose labels are to be listed + * @param boolean $byName Whether to return the label names (true) instead of the numeric label identifiers (false) + */ public function articleLabelsGet(string $user, $id, bool $byName = false): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $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(); - // flatten the result to return just the label ID or name, sorted - $out = $out ? array_column($out, !$byName ? "id" : "name") : []; - sort($out); - return $out; + $field = !$byName ? "id" : "name"; + $out = $this->db->prepare("SELECT $field from arsse_labels join arsse_label_members on arsse_label_members.label = arsse_labels.id where owner = ? and article = ? and assigned = 1 order by $field", "str", "int")->run($user, $id)->getAll(); + return $out ? array_column($out, $field) : []; } + /** Returns the author-supplied categories associated with an article */ public function articleCategoriesGet(string $user, $id): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1161,32 +1766,19 @@ class Database { } } + /** Deletes from the database articles which are beyond the configured clean-up threshold */ public function articleCleanup(): bool { $query = $this->db->prepare( - "WITH target_feed(id,subs) as (". - "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 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 - feed = (select max(id) from target_feed) - and id not in (select id from excepted_articles) - and (select count(*) from arsse_marks where article = arsse_articles.id and starred = 1) = 0 - and ( - coalesce((select max(modified) from arsse_marks where article = arsse_articles.id),modified) <= ? - or ((select max(subs) from target_feed) = (select count(*) from arsse_marks where article = arsse_articles.id and \"read\" = 1) and coalesce((select max(modified) from arsse_marks where article = arsse_articles.id),modified) <= ?) + "WITH RECURSIVE + exempt_articles as (SELECT id from arsse_articles join (SELECT article, max(id) as edition from arsse_editions group by article) as latest_editions on arsse_articles.id = latest_editions.article where feed = ? order by edition desc limit ?), + target_articles as ( + select id from arsse_articles + left join (select article, sum(starred) as starred, sum(\"read\") as \"read\", max(arsse_marks.modified) as marked_date from arsse_marks join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription group by article) as mark_stats on mark_stats.article = arsse_articles.id + left join (select feed, count(*) as subs from arsse_subscriptions group by feed) as feed_stats on feed_stats.feed = arsse_articles.feed + where arsse_articles.feed = ? and coalesce(starred,0) = 0 and (coalesce(marked_date,modified) <= ? or (coalesce(\"read\",0) = coalesce(subs,0) and coalesce(marked_date,modified) <= ?)) ) - ", + DELETE FROM arsse_articles WHERE id not in (select id from exempt_articles) and id in (select id from target_articles)", + "int", "int", "int", "datetime", @@ -1201,25 +1793,31 @@ class Database { $limitUnread = Date::sub(Arsse::$conf->purgeArticlesUnread); } $feeds = $this->db->query("SELECT id, size from arsse_feeds")->getAll(); + $deleted = 0; foreach ($feeds as $feed) { - $query->run($feed['id'], $feed['size'], $limitUnread, $limitRead); + $deleted += $query->run($feed['id'], $feed['size'], $feed['id'], $limitUnread, $limitRead)->changes(); } - return true; + return (bool) $deleted; } + /** Ensures the specified article exists and raises an exception otherwise + * + * Returns an associative array containing the id and latest edition of the article if it exists + * + * @param string $user The user who owns the article to be validated + * @param integer $id The identifier of the article to validate + */ protected function articleValidateId(string $user, $id): array { if (!ValueInfo::id($id)) { throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "article", 'type' => "int > 0"]); // @codeCoverageIgnore } $out = $this->db->prepare( - "SELECT - arsse_articles.id as article, - (select max(id) from arsse_editions where article = arsse_articles.id) as edition - FROM arsse_articles - join arsse_feeds on arsse_feeds.id = arsse_articles.feed - join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id - WHERE - arsse_articles.id = ? and arsse_subscriptions.owner = ?", + "SELECT articles.article as article, max(arsse_editions.id) as edition from ( + select arsse_articles.id as article + FROM arsse_articles + join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed + WHERE arsse_articles.id = ? and arsse_subscriptions.owner = ? + ) as articles join arsse_editions on arsse_editions.article = articles.article group by articles.article", "int", "str" )->run($id, $user)->getRow(); @@ -1229,21 +1827,25 @@ class Database { return $out; } + /** Ensures the specified article edition exists and raises an exception otherwise + * + * Returns an associative array containing the edition id, article id, and latest edition of the edition if it exists + * + * @param string $user The user who owns the edition to be validated + * @param integer $id The identifier of the edition to validate + */ protected function articleValidateEdition(string $user, int $id): array { if (!ValueInfo::id($id)) { throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "edition", 'type' => "int > 0"]); // @codeCoverageIgnore } $out = $this->db->prepare( "SELECT - arsse_editions.id as edition, - arsse_editions.article as article, - (arsse_editions.id = (select max(id) from arsse_editions where article = arsse_editions.article)) as current - FROM arsse_editions - join arsse_articles on arsse_editions.article = arsse_articles.id - join arsse_feeds on arsse_feeds.id = arsse_articles.feed - join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id - WHERE - arsse_editions.id = ? and arsse_subscriptions.owner = ?", + arsse_editions.id, arsse_editions.article, edition_stats.edition as current + from arsse_editions + join arsse_articles on arsse_articles.id = arsse_editions.article + join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed + join (select article, max(id) as edition from arsse_editions group by article) as edition_stats on edition_stats.article = arsse_editions.article + where arsse_editions.id = ? and arsse_subscriptions.owner = ?", "int", "str" )->run($id, $user)->getRow(); @@ -1253,6 +1855,7 @@ class Database { return array_map("intval", $out); } + /** Returns the numeric identifier of the most recent edition of an article matching the given context */ public function editionLatest(string $user, Context $context = null): int { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1268,25 +1871,22 @@ class Database { return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } + /** Returns a map between all the given edition identifiers and their associated article identifiers */ 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")) : []; - } - } - + list($in, $inTypes, $inValues) = $this->generateIn($context->editions, "int"); + $out = $this->db->prepare("SELECT id as edition, article from arsse_editions where id in($in)", $inTypes)->run($inValues)->getAll(); + return $out ? array_combine(array_column($out, "edition"), array_column($out, "article")) : []; + } + + /** Creates a label, and returns its numeric identifier + * + * Labels are discrete objects in the database and can be associated with multiple articles; an article may in turn be associated with multiple labels + * + * @param string $user The user who will own the created label + * @param array $data An associative array defining the label's properties; currently only "name" is understood + */ 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__)) { @@ -1299,6 +1899,18 @@ class Database { return $this->db->prepare("INSERT INTO arsse_labels(owner,name) values(?,?)", "str", "str")->run($user, $name)->lastId(); } + /** Lists a user's article labels + * + * The following keys are included in each record: + * + * - "id": The label's numeric identifier + * - "name" The label's textual name + * - "articles": The count of articles which have the label assigned to them + * - "read": How many of the total articles assigned to the label are read + * + * @param string $user The user whose labels are to be listed + * @param boolean $includeEmpty Whether to include (true) or supress (false) labels which have no articles assigned to them + */ public function labelList(string $user, bool $includeEmpty = true): Db\Result { // if the user isn't authorized to perform this action then throw an exception. if (!Arsse::$user->authorize($user, __FUNCTION__)) { @@ -1307,20 +1919,38 @@ class Database { return $this->db->prepare( "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 + id,name,coalesce(articles,0) as articles,coalesce(marked,0) as \"read\" + from arsse_labels + left join ( + SELECT label, sum(assigned) as articles from arsse_label_members group by label + ) as label_stats on label_stats.label = arsse_labels.id + left join ( + SELECT + label, sum(\"read\") as marked + from arsse_marks + join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription + join arsse_label_members on arsse_label_members.article = arsse_marks.article + where arsse_subscriptions.owner = ? + group by label + ) as mark_stats on mark_stats.label = arsse_labels.id + WHERE owner = ? + ) as label_data where articles >= ? order by name ", "str", + "str", "int" - )->run($user, !$includeEmpty); + )->run($user, $user, !$includeEmpty); } + /** Deletes a label from the database + * + * Any articles associated with the label remains untouched + * + * @param string $user The owner of the label to remove + * @param integer|string $id The numeric identifier or name of the label + * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) + */ public function labelRemove(string $user, $id, bool $byName = false): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1335,6 +1965,19 @@ class Database { return true; } + /** Retrieves the properties of a label + * + * The following keys are included in the output array: + * + * - "id": The label's numeric identifier + * - "name" The label's textual name + * - "articles": The count of articles which have the label assigned to them + * - "read": How many of the total articles assigned to the label are read + * + * @param string $user The owner of the label to remove + * @param integer|string $id The numeric identifier or name of the label + * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) + */ public function labelPropertiesGet(string $user, $id, bool $byName = false): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1344,23 +1987,39 @@ class Database { $type = $byName ? "str" : "int"; $out = $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 $field = ? and owner = ? + id,name,coalesce(articles,0) as articles,coalesce(marked,0) as \"read\" + FROM arsse_labels + left join ( + SELECT label, sum(assigned) as articles from arsse_label_members group by label + ) as label_stats on label_stats.label = arsse_labels.id + left join ( + SELECT + label, sum(\"read\") as marked + from arsse_marks + join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription + join arsse_label_members on arsse_label_members.article = arsse_marks.article + where arsse_subscriptions.owner = ? + group by label + ) as mark_stats on mark_stats.label = arsse_labels.id + WHERE $field = ? and owner = ? ", + "str", $type, "str" - )->run($id, $user)->getRow(); + )->run($user, $id, $user)->getRow(); if (!$out) { throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "label", 'id' => $id]); } return $out; } + /** Sets the properties of a label + * + * @param string $user The owner of the label to query + * @param integer|string $id The numeric identifier or name of the label + * @param array $data An associative array defining the label's properties; currently only "name" is understood + * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) + */ public function labelPropertiesSet(string $user, $id, array $data, bool $byName = false): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1386,6 +2045,12 @@ class Database { return $out; } + /** Returns an indexed array of article identifiers assigned to a label + * + * @param string $user The owner of the label to query + * @param integer|string $id The numeric identifier or name of the label + * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) + */ public function labelArticlesGet(string $user, $id, bool $byName = false): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1406,46 +2071,81 @@ class Database { } } - public function labelArticlesSet(string $user, $id, Context $context = null, bool $remove = false, bool $byName = false): int { + /** Makes or breaks associations between a given label and articles matching the given query context + * + * @param string $user The owner of the label + * @param integer|string $id The numeric identifier or name of the label + * @param Context $context The query context matching the desired articles + * @param int $mode Whether to add (ASSOC_ADD), remove (ASSOC_REMOVE), or replace with (ASSOC_REPLACE) the matching associations + * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) + */ + public function labelArticlesSet(string $user, $id, Context $context, int $mode = self::ASSOC_ADD, bool $byName = false): int { + if (!in_array($mode, [self::ASSOC_ADD, self::ASSOC_REMOVE, self::ASSOC_REPLACE])) { + throw new Exception("constantUnknown", $mode); // @codeCoverageIgnore + } if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } - // validate the label ID, and get the numeric ID if matching by name + // validate the tag ID, and get the numeric ID if matching by name $id = $this->labelValidateId($user, $id, $byName, true)['id']; - $context = $context ?? new Context; + // get the list of articles matching the context + $articles = iterator_to_array($this->articleList($user, $context ?? new Context)); + // an empty article list is a special case + if (!sizeof($articles)) { + if ($mode == self::ASSOC_REPLACE) { + // replacing with an empty set means setting everything to zero + return $this->db->prepare("UPDATE arsse_label_members set assigned = 0, modified = CURRENT_TIMESTAMP where label = ? and assigned = 1", "int")->run($id)->changes(); + } else { + // adding or removing is a no-op + return 0; + } + } else { + $articles = array_column($articles, "id"); + } + // prepare up to three queries: removing requires one, adding two, and replacing three + list($inClause, $inTypes, $inValues) = $this->generateIn($articles, "int"); + $updateQ = "UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article %in% ($inClause)"; + $updateT = ["bool", "int", "bool", $inTypes]; + $insertQ = "INSERT INTO arsse_label_members(label,article,subscription) SELECT ?,a.id,s.id from arsse_articles as a join arsse_subscriptions as s on a.feed = s.feed where s.owner = ? and a.id not in (select article from arsse_label_members where label = ?) and a.id in ($inClause)"; + $insertT = ["int", "str", "int", $inTypes]; + $clearQ = str_replace("%in%", "not in", $updateQ); + $clearT = $updateT; + $updateQ = str_replace("%in%", "in", $updateQ); + $qList = []; + switch ($mode) { + case self::ASSOC_REMOVE: + $qList[] = [$updateQ, $updateT, [false, $id, false, $inValues]]; // soft-delete any existing associations + break; + case self::ASSOC_ADD: + $qList[] = [$updateQ, $updateT, [true, $id, true, $inValues]]; // re-enable any previously soft-deleted association + $qList[] = [$insertQ, $insertT, [$id, $user, $id, $inValues]]; // insert any newly-required associations + break; + case self::ASSOC_REPLACE: + $qList[] = [$clearQ, $clearT, [false, $id, false, $inValues]]; // soft-delete any existing associations for articles not in the list + $qList[] = [$updateQ, $updateT, [true, $id, true, $inValues]]; // re-enable any previously soft-deleted association + $qList[] = [$insertQ, $insertT, [$id, $user, $id, $inValues]]; // insert any newly-required associations + break; + } + // execute them in a transaction $out = 0; - // wrap this UPDATE and INSERT together into a transaction $tr = $this->begin(); - // first update any existing entries with the removal or re-addition of their association - $q = $this->articleQuery($user, $context); - $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 <> ? 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, ["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( - "SELECT - ?,id, - (select id from arsse_subscriptions where owner = ? and arsse_subscriptions.feed = target_articles.feed) - FROM target_articles", - ["int", "str"], - [$id, $user] - ); - $out += $this->db->prepare("INSERT INTO arsse_label_members(label,article,subscription) ".$q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); + foreach ($qList as list($q, $t, $v)) { + $out += $this->db->prepare($q, ...$t)->run(...$v)->changes(); } - // commit the transaction $tr->commit(); return $out; } + /** Ensures the specified label identifier or name is valid (and optionally whether it exists) and raises an exception otherwise + * + * Returns an associative array containing the id, name of the label if it exists + * + * @param string $user The user who owns the label to be validated + * @param integer|string $id The numeric identifier or name of the label to validate + * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) + * @param boolean $checkDb Whether to check whether the label exists (true) or only if the identifier or name is syntactically valid (false) + * @param boolean $subject Whether the label is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails + */ protected function labelValidateId(string $user, $id, bool $byName, bool $checkDb = true, bool $subject = false): array { if (!$byName && !ValueInfo::id($id)) { // if we're not referring to a label by name and the ID is invalid, throw an exception @@ -1469,6 +2169,7 @@ class Database { ]; } + /** Ensures a prospective label name is syntactically valid and raises an exception otherwise */ protected function labelValidateName($name): bool { $info = ValueInfo::str($name); if ($info & (ValueInfo::NULL | ValueInfo::EMPTY)) { @@ -1481,4 +2182,305 @@ class Database { return true; } } + + /** Creates a tag, and returns its numeric identifier + * + * Tags are discrete objects in the database and can be associated with multiple subscriptions; a subscription may in turn be associated with multiple tags + * + * @param string $user The user who will own the created tag + * @param array $data An associative array defining the tag's properties; currently only "name" is understood + */ + public function tagAdd(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__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + // validate the tag name + $name = array_key_exists("name", $data) ? $data['name'] : ""; + $this->tagValidateName($name, true); + // perform the insert + return $this->db->prepare("INSERT INTO arsse_tags(owner,name) values(?,?)", "str", "str")->run($user, $name)->lastId(); + } + + /** Lists a user's subscription tags + * + * The following keys are included in each record: + * + * - "id": The tag's numeric identifier + * - "name" The tag's textual name + * - "subscriptions": The count of subscriptions which have the tag assigned to them + * + * @param string $user The user whose tags are to be listed + * @param boolean $includeEmpty Whether to include (true) or supress (false) tags which have no subscriptions assigned to them + */ + public function tagList(string $user, bool $includeEmpty = true): Db\Result { + // if the user isn't authorized to perform this action then throw an exception. + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + return $this->db->prepare( + "SELECT * FROM ( + SELECT + id,name,coalesce(subscriptions,0) as subscriptions + from arsse_tags + left join (SELECT tag, sum(assigned) as subscriptions from arsse_tag_members group by tag) as tag_stats on tag_stats.tag = arsse_tags.id + WHERE owner = ? + ) as tag_data + where subscriptions >= ? order by name + ", + "str", + "int" + )->run($user, !$includeEmpty); + } + + /** Lists the associations between all tags and subscription + * + * The following keys are included in each record: + * + * - "tag_id": The tag's numeric identifier + * - "tag_name" The tag's textual name + * - "subscription_id": The numeric identifier of the associated subscription + * - "subscription_name" The subscription's textual name + * + * @param string $user The user whose tags are to be listed + */ + public function tagSummarize(string $user): Db\Result { + // if the user isn't authorized to perform this action then throw an exception. + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + return $this->db->prepare( + "SELECT + arsse_tags.id as id, + arsse_tags.name as name, + arsse_tag_members.subscription as subscription + FROM arsse_tag_members + join arsse_tags on arsse_tags.id = arsse_tag_members.tag + WHERE arsse_tags.owner = ? and assigned = 1", + "str" + )->run($user); + } + + /** Deletes a tag from the database + * + * Any subscriptions associated with the tag remains untouched + * + * @param string $user The owner of the tag to remove + * @param integer|string $id The numeric identifier or name of the tag + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + */ + public function tagRemove(string $user, $id, bool $byName = false): bool { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $this->tagValidateId($user, $id, $byName, false); + $field = $byName ? "name" : "id"; + $type = $byName ? "str" : "int"; + $changes = $this->db->prepare("DELETE FROM arsse_tags where owner = ? and $field = ?", "str", $type)->run($user, $id)->changes(); + if (!$changes) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "tag", 'id' => $id]); + } + return true; + } + + /** Retrieves the properties of a tag + * + * The following keys are included in the output array: + * + * - "id": The tag's numeric identifier + * - "name" The tag's textual name + * - "subscriptions": The count of subscriptions which have the tag assigned to them + * + * @param string $user The owner of the tag to remove + * @param integer|string $id The numeric identifier or name of the tag + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + */ + public function tagPropertiesGet(string $user, $id, bool $byName = false): array { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $this->tagValidateId($user, $id, $byName, false); + $field = $byName ? "name" : "id"; + $type = $byName ? "str" : "int"; + $out = $this->db->prepare( + "SELECT + id,name,coalesce(subscriptions,0) as subscriptions + FROM arsse_tags + left join (SELECT tag, sum(assigned) as subscriptions from arsse_tag_members group by tag) as tag_stats on tag_stats.tag = arsse_tags.id + WHERE $field = ? and owner = ? + ", + $type, + "str" + )->run($id, $user)->getRow(); + if (!$out) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "tag", 'id' => $id]); + } + return $out; + } + + /** Sets the properties of a tag + * + * @param string $user The owner of the tag to query + * @param integer|string $id The numeric identifier or name of the tag + * @param array $data An associative array defining the tag's properties; currently only "name" is understood + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + */ + public function tagPropertiesSet(string $user, $id, array $data, bool $byName = false): bool { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + $this->tagValidateId($user, $id, $byName, false); + if (isset($data['name'])) { + $this->tagValidateName($data['name']); + } + $field = $byName ? "name" : "id"; + $type = $byName ? "str" : "int"; + $valid = [ + 'name' => "str", + ]; + list($setClause, $setTypes, $setValues) = $this->generateSet($data, $valid); + if (!$setClause) { + // if no changes would actually be applied, just return + return false; + } + $out = (bool) $this->db->prepare("UPDATE arsse_tags set $setClause, modified = CURRENT_TIMESTAMP where owner = ? and $field = ?", $setTypes, "str", $type)->run($setValues, $user, $id)->changes(); + if (!$out) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "tag", 'id' => $id]); + } + return $out; + } + + /** Returns an indexed array of subscription identifiers assigned to a tag + * + * @param string $user The owner of the tag to query + * @param integer|string $id The numeric identifier or name of the tag + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + */ + public function tagSubscriptionsGet(string $user, $id, bool $byName = false): array { + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + // just do a syntactic check on the tag ID + $this->tagValidateId($user, $id, $byName, false); + $field = !$byName ? "id" : "name"; + $type = !$byName ? "int" : "str"; + $out = $this->db->prepare("SELECT subscription from arsse_tag_members join arsse_tags on tag = id where assigned = 1 and $field = ? and owner = ? order by subscription", $type, "str")->run($id, $user)->getAll(); + if (!$out) { + // if no results were returned, do a full validation on the tag ID + $this->tagValidateId($user, $id, $byName, true, true); + // if the validation passes, return the empty result + return $out; + } else { + // flatten the result to return just the subscription IDs in a simple array + return array_column($out, "subscription"); + } + } + + /** Makes or breaks associations between a given tag and specified subscriptions + * + * @param string $user The owner of the tag + * @param integer|string $id The numeric identifier or name of the tag + * @param integer[] $subscriptions An array listing the desired subscriptions + * @param int $mode Whether to add (ASSOC_ADD), remove (ASSOC_REMOVE), or replace with (ASSOC_REPLACE) the listed associations + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + */ + public function tagSubscriptionsSet(string $user, $id, array $subscriptions, int $mode = self::ASSOC_ADD, bool $byName = false): int { + if (!in_array($mode, [self::ASSOC_ADD, self::ASSOC_REMOVE, self::ASSOC_REPLACE])) { + throw new Exception("constantUnknown", $mode); // @codeCoverageIgnore + } + if (!Arsse::$user->authorize($user, __FUNCTION__)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + } + // validate the tag ID, and get the numeric ID if matching by name + $id = $this->tagValidateId($user, $id, $byName, true)['id']; + // an empty subscription list is a special case + if (!sizeof($subscriptions)) { + if ($mode == self::ASSOC_REPLACE) { + // replacing with an empty set means setting everything to zero + return $this->db->prepare("UPDATE arsse_tag_members set assigned = 0, modified = CURRENT_TIMESTAMP where tag = ? and assigned = 1", "int")->run($id)->changes(); + } else { + // adding or removing is a no-op + return 0; + } + } + // prepare up to three queries: removing requires one, adding two, and replacing three + list($inClause, $inTypes, $inValues) = $this->generateIn($subscriptions, "int"); + $updateQ = "UPDATE arsse_tag_members set assigned = ?, modified = CURRENT_TIMESTAMP where tag = ? and assigned <> ? and subscription in (select id from arsse_subscriptions where owner = ? and id %in% ($inClause))"; + $updateT = ["bool", "int", "bool", "str", $inTypes]; + $insertQ = "INSERT INTO arsse_tag_members(tag,subscription) SELECT ?,id from arsse_subscriptions where id not in (select subscription from arsse_tag_members where tag = ?) and owner = ? and id in ($inClause)"; + $insertT = ["int", "int", "str", $inTypes]; + $clearQ = str_replace("%in%", "not in", $updateQ); + $clearT = $updateT; + $updateQ = str_replace("%in%", "in", $updateQ); + $qList = []; + switch ($mode) { + case self::ASSOC_REMOVE: + $qList[] = [$updateQ, $updateT, [0, $id, 0, $user, $inValues]]; // soft-delete any existing associations + break; + case self::ASSOC_ADD: + $qList[] = [$updateQ, $updateT, [1, $id, 1, $user, $inValues]]; // re-enable any previously soft-deleted association + $qList[] = [$insertQ, $insertT, [$id, $id, $user, $inValues]]; // insert any newly-required associations + break; + case self::ASSOC_REPLACE: + $qList[] = [$clearQ, $clearT, [0, $id, 0, $user, $inValues]]; // soft-delete any existing associations for subscriptions not in the list + $qList[] = [$updateQ, $updateT, [1, $id, 1, $user, $inValues]]; // re-enable any previously soft-deleted association + $qList[] = [$insertQ, $insertT, [$id, $id, $user, $inValues]]; // insert any newly-required associations + break; + } + // execute them in a transaction + $out = 0; + $tr = $this->begin(); + foreach ($qList as list($q, $t, $v)) { + $out += $this->db->prepare($q, ...$t)->run(...$v)->changes(); + } + $tr->commit(); + return $out; + } + + /** Ensures the specified tag identifier or name is valid (and optionally whether it exists) and raises an exception otherwise + * + * Returns an associative array containing the id, name of the tag if it exists + * + * @param string $user The user who owns the tag to be validated + * @param integer|string $id The numeric identifier or name of the tag to validate + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + * @param boolean $checkDb Whether to check whether the tag exists (true) or only if the identifier or name is syntactically valid (false) + * @param boolean $subject Whether the tag is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails + */ + protected function tagValidateId(string $user, $id, bool $byName, bool $checkDb = true, bool $subject = false): array { + if (!$byName && !ValueInfo::id($id)) { + // if we're not referring to a tag by name and the ID is invalid, throw an exception + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "tag", 'type' => "int > 0"]); + } elseif ($byName && !(ValueInfo::str($id) & ValueInfo::VALID)) { + // otherwise if we are referring to a tag by name but the ID is not a string, also throw an exception + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "tag", 'type' => "string"]); + } elseif ($checkDb) { + $field = !$byName ? "id" : "name"; + $type = !$byName ? "int" : "str"; + $l = $this->db->prepare("SELECT id,name from arsse_tags where $field = ? and owner = ?", $type, "str")->run($id, $user)->getRow(); + if (!$l) { + throw new Db\ExceptionInput($subject ? "subjectMissing" : "idMissing", ["action" => $this->caller(), "field" => "tag", 'id' => $id]); + } else { + return $l; + } + } + return [ + 'id' => !$byName ? $id : null, + 'name' => $byName ? $id : null, + ]; + } + + /** Ensures a prospective tag name is syntactically valid and raises an exception otherwise */ + protected function tagValidateName($name): bool { + $info = ValueInfo::str($name); + if ($info & (ValueInfo::NULL | ValueInfo::EMPTY)) { + throw new Db\ExceptionInput("missing", ["action" => $this->caller(), "field" => "name"]); + } elseif ($info & ValueInfo::WHITE) { + throw new Db\ExceptionInput("whitespace", ["action" => $this->caller(), "field" => "name"]); + } elseif (!($info & ValueInfo::VALID)) { + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "name", 'type' => "string"]); + } else { + return true; + } + } } diff --git a/lib/Db/AbstractStatement.php b/lib/Db/AbstractStatement.php index 1dc990f..abf9f77 100644 --- a/lib/Db/AbstractStatement.php +++ b/lib/Db/AbstractStatement.php @@ -12,11 +12,25 @@ use JKingWeb\Arsse\Misc\ValueInfo; abstract class AbstractStatement implements Statement { use SQLState; + const TYPE_NORM_MAP = [ + self::T_INTEGER => ValueInfo::M_NULL | ValueInfo::T_INT, + self::T_STRING => ValueInfo::M_NULL | ValueInfo::T_STRING, + self::T_BOOLEAN => ValueInfo::M_NULL | ValueInfo::T_BOOL, + self::T_DATETIME => ValueInfo::M_NULL | ValueInfo::T_DATE, + self::T_FLOAT => ValueInfo::M_NULL | ValueInfo::T_FLOAT, + self::T_BINARY => ValueInfo::M_NULL | ValueInfo::T_STRING, + self::T_NOT_NULL + self::T_INTEGER => ValueInfo::T_INT, + self::T_NOT_NULL + self::T_STRING => ValueInfo::T_STRING, + self::T_NOT_NULL + self::T_BOOLEAN => ValueInfo::T_BOOL, + self::T_NOT_NULL + self::T_DATETIME => ValueInfo::T_DATE, + self::T_NOT_NULL + self::T_FLOAT => ValueInfo::T_FLOAT, + self::T_NOT_NULL + self::T_BINARY => ValueInfo::T_STRING, + ]; + protected $types = []; - protected $isNullable = []; abstract public function runArray(array $values = []): Result; - abstract protected function bindValue($value, string $type, int $position): bool; + abstract protected function bindValue($value, int $type, int $position): bool; abstract protected function prepare(string $query): bool; abstract protected static function buildEngineException($code, string $msg): array; @@ -41,18 +55,11 @@ abstract class AbstractStatement implements Statement { // recursively flatten any arrays, which may be provided for SET or IN() clauses $this->retypeArray($binding, true); } else { - $binding = trim(strtolower($binding)); - if (strpos($binding, "strict ")===0) { - // "strict" types' values may never be null; null values will later be cast to the type specified - $this->isNullable[] = false; - $binding = substr($binding, 7); - } else { - $this->isNullable[] = true; - } - if (!array_key_exists($binding, self::TYPES)) { + $bindId = self::TYPES[trim(strtolower($binding))] ?? 0; + if (!$bindId) { throw new Exception("paramTypeInvalid", $binding); // @codeCoverageIgnore } - $this->types[] = self::TYPES[$binding]; + $this->types[] = $bindId; } } if (!$append) { @@ -61,27 +68,16 @@ abstract class AbstractStatement implements Statement { return true; } - protected function cast($v, string $t, bool $nullable) { + protected function cast($v, int $t) { switch ($t) { - case "datetime": + case self::T_DATETIME: + return Date::transform($v, "sql"); + case self::T_DATETIME + self::T_NOT_NULL: $v = Date::transform($v, "sql"); - if (is_null($v) && !$nullable) { - $v = 0; - $v = Date::transform($v, "sql"); - } - return $v; - case "integer": - return ValueInfo::normalize($v, ValueInfo::T_INT | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); - case "float": - return ValueInfo::normalize($v, ValueInfo::T_FLOAT | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); - case "binary": - case "string": - return ValueInfo::normalize($v, ValueInfo::T_STRING | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); - case "boolean": - $v = ValueInfo::normalize($v, ValueInfo::T_BOOL | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); - return is_null($v) ? $v : (int) $v; + return $v ? $v : "0001-01-01 00:00:00"; default: - throw new Exception("paramTypeUnknown", $type); // @codeCoverageIgnore + $v = ValueInfo::normalize($v, self::TYPE_NORM_MAP[$t], null, "sql"); + return is_bool($v) ? (int) $v : $v; } } @@ -92,8 +88,8 @@ abstract class AbstractStatement implements Statement { // 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); + $value = $this->cast($value, $this->types[$a]); + $this->bindValue($value, $this->types[$a] % self::T_NOT_NULL, ++$a); } else { throw new Exception("paramTypeMissing", $a+1); } @@ -102,7 +98,7 @@ abstract class AbstractStatement implements Statement { // 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); + $this->bindValue(null, $this->types[$a] % self::T_NOT_NULL, ++$a); } } return $a - $offset; diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index 64eca65..a456fba 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -13,32 +13,79 @@ interface Driver { const TR_PEND_COMMIT = -1; const TR_PEND_ROLLBACK = -2; + /** Creates and returns an instance of the class; this is so that either a native or PDO driver may be returned depending on what is available on the server */ public static function create(): Driver; - // returns a human-friendly name for the driver (for display in installer, for example) + + /** Returns a human-friendly name for the driver */ public static function driverName(): string; - // returns the version of the scheme of the opened database; if uninitialized should return 0 + + /** Returns the version of the schema of the opened database; if uninitialized should return 0 + * + * Normally the version is stored under the 'schema_version' key in the arsse_meta table, but another method may be used if appropriate + */ public function schemaVersion(): int; - // returns the schema set to be used for database set-up + + /** Returns the schema set to be used for database set-up */ public static function schemaID(): string; - // return a Transaction object + + /** Returns a Transaction object */ public function begin(bool $lock = false): Transaction; - // manually begin a real or synthetic transactions, with real or synthetic nesting + + /** Manually begins a real or synthetic transactions, with real or synthetic nesting, and returns its numeric ID + * + * If the database backend does not implement savepoints, IDs must still be tracked as if it does + */ public function savepointCreate(): int; - // manually commit either the latest or all pending nested transactions + + /** Manually commits either the latest or a specified nested transaction */ public function savepointRelease(int $index = null): bool; - // manually rollback either the latest or all pending nested transactions + + /** Manually rolls back either the latest or a specified nested transaction */ public function savepointUndo(int $index = null): bool; - // attempt to perform an in-place upgrade of the database schema; this may be a no-op which always throws an exception + + /** Performs an in-place upgrade of the database schema + * + * The driver may choose not to implement in-place upgrading, in which case an exception should be thrown + */ public function schemaUpdate(int $to): bool; - // execute one or more unsanitized SQL queries and return an indication of success + + /** Executes one or more queries without parameters, returning only an indication of success */ public function exec(string $query): bool; - // perform a single unsanitized query and return a result set + + /** Executes a single query without parameters, and returns a result set */ public function query(string $query): Result; - // ready a prepared statement for later execution + + /** Readies a prepared statement for later execution */ public function prepare(string $query, ...$paramType): Statement; + + /** Readies a prepared statement for later execution */ public function prepareArray(string $query, array $paramTypes): Statement; - // report whether the database character set is correct/acceptable + + /** Reports whether the database character set is correct/acceptable + * + * The backend must be able to accept and provide UTF-8 text; information may be stored in any encoding capable of representing the entire range of Unicode + */ public function charsetAcceptable(): bool; - // return an implementation-dependent form of a reference SQL function or operator + + /** Returns an implementation-dependent form of a reference SQL function or operator + * + * The tokens the implementation must understand are: + * + * - "greatest": the GREATEST function implemented by PostgreSQL and MySQL + * - "nocase": the name of a general-purpose case-insensitive collation sequence + * - "like": the case-insensitive LIKE operator + */ public function sqlToken(string $token): string; + + /** Returns a string literal which is properly escaped to guard against SQL injections. Delimiters are included in the output string + * + * This functionality should be avoided in favour of using statement parameters whenever possible + */ + public function literalString(string $str): string; + + /** Performs implementation-specific database maintenance to ensure good performance + * + * This should be restricted to quick maintenance; in SQLite terms it might include ANALYZE, but not VACUUM + */ + public function maintenance(): bool; } diff --git a/lib/Db/ExceptionRetry.php b/lib/Db/ExceptionRetry.php new file mode 100644 index 0000000..be4769a --- /dev/null +++ b/lib/Db/ExceptionRetry.php @@ -0,0 +1,10 @@ +exec($q); } // get the maximum packet size; parameter strings larger than this size need to be chunked - $this->packetSize = (int) $this->query("select variable_value from performance_schema.session_variables where variable_name = 'max_allowed_packet'")->getValue(); + $this->packetSize = (int) $this->query("SELECT variable_value from performance_schema.session_variables where variable_name = 'max_allowed_packet'")->getValue(); } public static function makeSetupQueries(): array { @@ -212,4 +212,21 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new Statement($this->db, $query, $paramTypes, $this->packetSize); } + + public function literalString(string $str): string { + return "'".$this->db->real_escape_string($str)."'"; + } + + public function maintenance(): bool { + // with MySQL each table must be analyzed separately, so we first have to get a list of tables + foreach ($this->query("SHOW TABLES like 'arsse\\_%'") as $table) { + $table = array_pop($table); + if (!preg_match("/^arsse_[a-z_]+$/", $table)) { + // table is not one of ours + continue; // @codeCoverageIgnore + } + $this->query("ANALYZE TABLE $table"); + } + return true; + } } diff --git a/lib/Db/MySQL/Statement.php b/lib/Db/MySQL/Statement.php index 9612615..acbf4a5 100644 --- a/lib/Db/MySQL/Statement.php +++ b/lib/Db/MySQL/Statement.php @@ -14,12 +14,12 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { use ExceptionBuilder; const BINDINGS = [ - "integer" => "i", - "float" => "d", - "datetime" => "s", - "binary" => "b", - "string" => "s", - "boolean" => "i", + self::T_INTEGER => "i", + self::T_FLOAT => "d", + self::T_DATETIME => "s", + self::T_BINARY => "b", + self::T_STRING => "s", + self::T_BOOLEAN => "i", ]; protected $db; @@ -93,11 +93,11 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { return new Result($r, [$changes, $lastId], $this); } - protected function bindValue($value, string $type, int $position): bool { + protected function bindValue($value, int $type, int $position): bool { // this is a bit of a hack: we collect values (and MySQL bind types) here so that we can take // advantage of the work done by bindValues() even though MySQL requires everything to be bound // all at once; we also segregate large values for later packetization - if (($type === "binary" && !is_null($value)) || (is_string($value) && strlen($value) > $this->packetSize)) { + if (($type == self::T_BINARY && !is_null($value)) || (is_string($value) && strlen($value) > $this->packetSize)) { $this->values[] = null; $this->longs[$position - 1] = $value; $this->binds .= "b"; @@ -112,7 +112,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { $out = ""; for ($b = 1; $b < sizeof($query); $b++) { $a = $b - 1; - $mark = (($types[$a] ?? "") === "datetime") ? "cast(? as datetime(0))" : "?"; + $mark = (($types[$a] ?? 0) % self::T_NOT_NULL == self::T_DATETIME) ? "cast(? as datetime(0))" : "?"; $out .= $query[$a].$mark; } $out .= array_pop($query); diff --git a/lib/Db/PDODriver.php b/lib/Db/PDODriver.php index c5b4f4d..df5dcc3 100644 --- a/lib/Db/PDODriver.php +++ b/lib/Db/PDODriver.php @@ -28,4 +28,8 @@ trait PDODriver { } return new PDOResult($this->db, $r); } + + public function literalString(string $str): string { + return $this->db->quote($str); + } } diff --git a/lib/Db/PDOStatement.php b/lib/Db/PDOStatement.php index 594ecf8..2175231 100644 --- a/lib/Db/PDOStatement.php +++ b/lib/Db/PDOStatement.php @@ -10,12 +10,12 @@ abstract class PDOStatement extends AbstractStatement { use PDOError; const BINDINGS = [ - "integer" => \PDO::PARAM_INT, - "float" => \PDO::PARAM_STR, - "datetime" => \PDO::PARAM_STR, - "binary" => \PDO::PARAM_LOB, - "string" => \PDO::PARAM_STR, - "boolean" => \PDO::PARAM_INT, // FIXME: using \PDO::PARAM_BOOL leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 + self::T_INTEGER => \PDO::PARAM_INT, + self::T_FLOAT => \PDO::PARAM_STR, + self::T_DATETIME => \PDO::PARAM_STR, + self::T_BINARY => \PDO::PARAM_LOB, + self::T_STRING => \PDO::PARAM_STR, + self::T_BOOLEAN => \PDO::PARAM_INT, // FIXME: using \PDO::PARAM_BOOL leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 ]; protected $st; @@ -55,7 +55,7 @@ abstract class PDOStatement extends AbstractStatement { return new PDOResult($this->db, $this->st); } - protected function bindValue($value, string $type, int $position): bool { + protected function bindValue($value, int $type, int $position): bool { return $this->st->bindValue($position, $value, is_null($value) ? \PDO::PARAM_NULL : self::BINDINGS[$type]); } } diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php index 95496ea..5dd17ef 100644 --- a/lib/Db/PostgreSQL/Driver.php +++ b/lib/Db/PostgreSQL/Driver.php @@ -120,6 +120,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { switch (strtolower($token)) { case "nocase": return '"und-x-icu"'; + case "like": + return "ilike"; default: return $token; } @@ -219,4 +221,14 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new Statement($this->db, $query, $paramTypes); } + + public function literalString(string $str): string { + return pg_escape_literal($this->db, $str); + } + + public function maintenance(): bool { + // analyze the database + $this->exec("ANALYZE"); + return true; + } } diff --git a/lib/Db/PostgreSQL/Statement.php b/lib/Db/PostgreSQL/Statement.php index df74e3d..f5040f2 100644 --- a/lib/Db/PostgreSQL/Statement.php +++ b/lib/Db/PostgreSQL/Statement.php @@ -14,12 +14,12 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { use Dispatch; const BINDINGS = [ - "integer" => "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 + self::T_INTEGER => "bigint", + self::T_FLOAT => "decimal", + self::T_DATETIME => "timestamp(0) without time zone", + self::T_BINARY => "bytea", + self::T_STRING => "text", + self::T_BOOLEAN => "smallint", // FIXME: using boolean leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 ]; protected $db; @@ -47,7 +47,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { } } - protected function bindValue($value, string $type, int $position): bool { + protected function bindValue($value, int $type, int $position): bool { $this->in[] = $value; return true; } @@ -59,7 +59,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { for ($b = 1; $b < sizeof($q); $b++) { $a = $b - 1; $mark = $mungeParamMarkers ? "\$$b" : "?"; - $type = isset($types[$a]) ? "::".self::BINDINGS[$types[$a]] : ""; + $type = isset($types[$a]) ? "::".self::BINDINGS[$types[$a] % self::T_NOT_NULL] : ""; $out .= $q[$a].$mark.$type; } $out .= array_pop($q); diff --git a/lib/Db/SQLite3/AbstractPDODriver.php b/lib/Db/SQLite3/AbstractPDODriver.php new file mode 100644 index 0000000..a743a25 --- /dev/null +++ b/lib/Db/SQLite3/AbstractPDODriver.php @@ -0,0 +1,11 @@ +exec("PRAGMA journal_mode = wal"); + } // turn off foreign keys $this->exec("PRAGMA foreign_keys = no"); // run the generic updater @@ -179,4 +184,14 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { $this->exec((!$rollback) ? "COMMIT" : "ROLLBACK"); return true; } + + public function literalString(string $str): string { + return "'".\SQLite3::escapeString($str)."'"; + } + + public function maintenance(): bool { + // analyze the database then checkpoint and truncate the write-ahead log + $this->exec("ANALYZE; PRAGMA wal_checkpoint(truncate)"); + return true; + } } diff --git a/lib/Db/SQLite3/ExceptionBuilder.php b/lib/Db/SQLite3/ExceptionBuilder.php index 9e3bfff..22d1723 100644 --- a/lib/Db/SQLite3/ExceptionBuilder.php +++ b/lib/Db/SQLite3/ExceptionBuilder.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Db\SQLite3; use JKingWeb\Arsse\Db\Exception; +use JKingWeb\Arsse\Db\ExceptionRetry; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionTimeout; @@ -19,6 +20,9 @@ trait ExceptionBuilder { switch ($code) { case Driver::SQLITE_BUSY: return [ExceptionTimeout::class, 'general', $msg]; + case Driver::SQLITE_SCHEMA: + // sometimes encountered with PDO, because PDO sucks + return [ExceptionRetry::class, 'schemaChange', $msg]; // @codeCoverageIgnore case Driver::SQLITE_CONSTRAINT: return [ExceptionInput::class, 'engineConstraintViolation', $msg]; case Driver::SQLITE_MISMATCH: diff --git a/lib/Db/SQLite3/PDODriver.php b/lib/Db/SQLite3/PDODriver.php index c36a3c1..c6d7ad4 100644 --- a/lib/Db/SQLite3/PDODriver.php +++ b/lib/Db/SQLite3/PDODriver.php @@ -11,9 +11,7 @@ use JKingWeb\Arsse\Db\Exception; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionTimeout; -class PDODriver extends Driver { - use \JKingWeb\Arsse\Db\PDODriver; - +class PDODriver extends AbstractPDODriver { protected $db; public static function requirementsMet(): bool { @@ -49,4 +47,40 @@ class PDODriver extends Driver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new PDOStatement($this->db, $query, $paramTypes); } + + /** @codeCoverageIgnore */ + public function exec(string $query): bool { + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // we have to retry ourselves in cases of schema changes + // the SQLite3 class is not similarly affected + $attempts = 0; + retry: + try { + return parent::exec($query); + } catch (\JKingWeb\Arsse\Db\ExceptionRetry $e) { + if (++$attempts > 50) { + throw $e; + } else { + goto retry; + } + } + } + + /** @codeCoverageIgnore */ + public function query(string $query): \JKingWeb\Arsse\Db\Result { + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // we have to retry ourselves in cases of schema changes + // the SQLite3 class is not similarly affected + $attempts = 0; + retry: + try { + return parent::query($query); + } catch (\JKingWeb\Arsse\Db\ExceptionRetry $e) { + if (++$attempts > 50) { + throw $e; + } else { + goto retry; + } + } + } } diff --git a/lib/Db/SQLite3/PDOStatement.php b/lib/Db/SQLite3/PDOStatement.php index 7e7642d..eb4fdfe 100644 --- a/lib/Db/SQLite3/PDOStatement.php +++ b/lib/Db/SQLite3/PDOStatement.php @@ -9,4 +9,23 @@ namespace JKingWeb\Arsse\Db\SQLite3; class PDOStatement extends \JKingWeb\Arsse\Db\PDOStatement { use ExceptionBuilder; use \JKingWeb\Arsse\Db\PDOError; + + /** @codeCoverageIgnore */ + public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result { + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // we have to retry ourselves in cases of schema changes + // the SQLite3 class is not similarly affected + $attempts = 0; + retry: + try { + return parent::runArray($values); + } catch (\JKingWeb\Arsse\Db\ExceptionRetry $e) { + if (++$attempts > 50) { + throw $e; + } else { + $this->st = $this->db->prepare($this->st->queryString); + goto retry; + } + } + } } diff --git a/lib/Db/SQLite3/Statement.php b/lib/Db/SQLite3/Statement.php index a0fb0cd..bfae44d 100644 --- a/lib/Db/SQLite3/Statement.php +++ b/lib/Db/SQLite3/Statement.php @@ -17,12 +17,12 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { const SQLITE_CONSTRAINT = 19; const SQLITE_MISMATCH = 20; const BINDINGS = [ - "integer" => \SQLITE3_INTEGER, - "float" => \SQLITE3_FLOAT, - "datetime" => \SQLITE3_TEXT, - "binary" => \SQLITE3_BLOB, - "string" => \SQLITE3_TEXT, - "boolean" => \SQLITE3_INTEGER, + self::T_INTEGER => \SQLITE3_INTEGER, + self::T_FLOAT => \SQLITE3_FLOAT, + self::T_DATETIME => \SQLITE3_TEXT, + self::T_BINARY => \SQLITE3_BLOB, + self::T_STRING => \SQLITE3_TEXT, + self::T_BOOLEAN => \SQLITE3_INTEGER, ]; protected $db; @@ -68,7 +68,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { return new Result($r, [$changes, $lastId], $this); } - protected function bindValue($value, string $type, int $position): bool { + protected function bindValue($value, int $type, int $position): bool { return $this->st->bindValue($position, $value, is_null($value) ? \SQLITE3_NULL : self::BINDINGS[$type]); } } diff --git a/lib/Db/Statement.php b/lib/Db/Statement.php index b59e075..0ed8685 100644 --- a/lib/Db/Statement.php +++ b/lib/Db/Statement.php @@ -8,24 +8,48 @@ namespace JKingWeb\Arsse\Db; interface Statement { const TYPES = [ - "int" => "integer", - "integer" => "integer", - "float" => "float", - "double" => "float", - "real" => "float", - "numeric" => "float", - "datetime" => "datetime", - "timestamp" => "datetime", - "blob" => "binary", - "bin" => "binary", - "binary" => "binary", - "text" => "string", - "string" => "string", - "str" => "string", - "bool" => "boolean", - "boolean" => "boolean", - "bit" => "boolean", + 'int' => self::T_INTEGER, + 'integer' => self::T_INTEGER, + 'float' => self::T_FLOAT, + 'double' => self::T_FLOAT, + 'real' => self::T_FLOAT, + 'numeric' => self::T_FLOAT, + 'datetime' => self::T_DATETIME, + 'timestamp' => self::T_DATETIME, + 'blob' => self::T_BINARY, + 'bin' => self::T_BINARY, + 'binary' => self::T_BINARY, + 'text' => self::T_STRING, + 'string' => self::T_STRING, + 'str' => self::T_STRING, + 'bool' => self::T_BOOLEAN, + 'boolean' => self::T_BOOLEAN, + 'bit' => self::T_BOOLEAN, + 'strict int' => self::T_NOT_NULL + self::T_INTEGER, + 'strict integer' => self::T_NOT_NULL + self::T_INTEGER, + 'strict float' => self::T_NOT_NULL + self::T_FLOAT, + 'strict double' => self::T_NOT_NULL + self::T_FLOAT, + 'strict real' => self::T_NOT_NULL + self::T_FLOAT, + 'strict numeric' => self::T_NOT_NULL + self::T_FLOAT, + 'strict datetime' => self::T_NOT_NULL + self::T_DATETIME, + 'strict timestamp' => self::T_NOT_NULL + self::T_DATETIME, + 'strict blob' => self::T_NOT_NULL + self::T_BINARY, + 'strict bin' => self::T_NOT_NULL + self::T_BINARY, + 'strict binary' => self::T_NOT_NULL + self::T_BINARY, + 'strict text' => self::T_NOT_NULL + self::T_STRING, + 'strict string' => self::T_NOT_NULL + self::T_STRING, + 'strict str' => self::T_NOT_NULL + self::T_STRING, + 'strict bool' => self::T_NOT_NULL + self::T_BOOLEAN, + 'strict boolean' => self::T_NOT_NULL + self::T_BOOLEAN, + 'strict bit' => self::T_NOT_NULL + self::T_BOOLEAN, ]; + const T_INTEGER = 1; + const T_STRING = 2; + const T_BOOLEAN = 3; + const T_DATETIME = 4; + const T_FLOAT = 5; + const T_BINARY = 6; + const T_NOT_NULL = 100; public function run(...$values): Result; public function runArray(array $values = []): Result; diff --git a/lib/ImportExport/AbstractImportExport.php b/lib/ImportExport/AbstractImportExport.php new file mode 100644 index 0000000..f882ea1 --- /dev/null +++ b/lib/ImportExport/AbstractImportExport.php @@ -0,0 +1,167 @@ +exists($user)) { + throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); + } + // first extract useful information from the input + list($feeds, $folders) = $this->parse($data, $flat); + $folderMap = []; + foreach ($folders as $f) { + // check to make sure folder names are all valid + if (!strlen(trim($f['name']))) { + throw new Exception("invalidFolderName"); + } + // check for duplicates + if (!isset($folderMap[$f['parent']])) { + $folderMap[$f['parent']] = []; + } + if (isset($folderMap[$f['parent']][$f['name']])) { + throw new Exception("invalidFolderCopy"); + } else { + $folderMap[$f['parent']][$f['name']] = true; + } + } + // get feed IDs for each URL, adding feeds where necessary + foreach ($feeds as $k => $f) { + $feeds[$k]['id'] = Arsse::$db->feedAdd(($f['url'])); + } + // start a transaction for atomic rollback + $tr = Arsse::$db->begin(); + // get current state of database + $foldersDb = iterator_to_array(Arsse::$db->folderList($user)); + $feedsDb = iterator_to_array(Arsse::$db->subscriptionList($user)); + $tagsDb = iterator_to_array(Arsse::$db->tagList($user)); + // reconcile folders + $folderMap = [0 => 0]; + foreach ($folders as $id => $f) { + $parent = $folderMap[$f['parent']]; + // find a match for the import folder in the existing folders + foreach ($foldersDb as $db) { + if ((int) $db['parent'] == $parent && $db['name'] === $f['name']) { + $folderMap[$id] = (int) $db['id']; + break; + } + } + if (!isset($folderMap[$id])) { + // if no existing folder exists, add one + $folderMap[$id] = Arsse::$db->folderAdd($user, ['name' => $f['name'], 'parent' -> $parent]); + } + } + // process newsfeed subscriptions + $feedMap = []; + $tagMap = []; + foreach ($feeds as $f) { + $folder = $folderMap[$f['folder']]; + $title = strlen(trim($f['title'])) ? $f['title'] : null; + $found = false; + // find a match for the import feed is existing subscriptions + foreach ($feedsDb as $db) { + if ((int) $db['feed'] == $f['id']) { + $found = true; + $feedMap[$f['id']] = (int) $db['id']; + break; + } + } + if (!$found) { + // if no subscription exists, add one + $feedMap[$f['id']] = Arsse::$db->subscriptionAdd($user, $f['url']); + } + if (!$found || $replace) { + // set the subscription's properties, if this is a new feed or we're doing a full replacement + Arsse::$db->subscriptionPropertiesSet($user, $feedMap[$f['id']], ['title' => $title, 'folder' => $folder]); + // compile the set of used tags, if this is a new feed or we're doing a full replacement + foreach ($f['tags'] as $t) { + if (!strlen(trim($t))) { + // fail if we have any blank tags + throw new Exception("invalidTagName"); + } + if (!isset($tagMap[$t])) { + // populate the tag map + $tagMap[$t] = []; + } + $tagMap[$t][] = $f['id']; + } + } + } + // set tags + $mode = $replace ? Database::ASSOC_REPLACE : Database::ASSOC_ADD; + foreach ($tagMap as $tag => $subs) { + // make sure the tag exists + $found = false; + foreach ($tagsDb as $db) { + if ($tag === $db['name']) { + $found = true; + break; + } + } + if (!$found) { + // add the tag if it wasn't found + Arsse::$db->tagAdd($user, ['name' => $tag]); + } + Arsse::$db->tagSubscriptionsSet($user, $tag, $subs, $mode, true); + } + // finally, if we're performing a replacement, delete any subscriptions, folders, or tags which were not present in the import + if ($replace) { + foreach (array_diff(array_column($feedsDb, "id"), $feedMap) as $id) { + try { + Arsse::$db->subscriptionRemove($user, $id); + } catch (InputException $e) { + // ignore errors + } + } + foreach (array_diff(array_column($foldersDb, "id"), $folderMap) as $id) { + try { + Arsse::$db->folderRemove($user, $id); + } catch (InputException $e) { + // ignore errors + } + } + foreach (array_diff(array_column($tagsDb, "name"), array_keys($tagMap)) as $id) { + try { + Arsse::$db->tagRemove($user, $id, true); + } catch (InputException $e) { + // ignore errors + } + } + } + $tr->commit(); + return true; + } + + abstract protected function parse(string $data, bool $flat): array; + + abstract public function export(string $user, bool $flat = false): string; + + public function exportFile(string $file, string $user, bool $flat = false): bool { + $data = $this->export($user, $flat); + if (!@file_put_contents($file, $data)) { + // if it fails throw an exception + $err = file_exists($file) ? "fileUnwritable" : "fileUncreatable"; + throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", get_class($this))]); + } + return true; + } + + public function importFile(string $file, string $user, bool $flat = false, bool $replace = false): bool { + $data = @file_get_contents($file); + if ($data === false) { + // if it fails throw an exception + $err = file_exists($file) ? "fileUnreadable" : "fileMissing"; + throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", get_class($this))]); + } + return $this->import($user, $data, $flat, $replace); + } +} diff --git a/lib/ImportExport/Exception.php b/lib/ImportExport/Exception.php new file mode 100644 index 0000000..888cfca --- /dev/null +++ b/lib/ImportExport/Exception.php @@ -0,0 +1,10 @@ +loadXML($opml)) { + // not a valid XML document + $err = libxml_get_last_error(); + throw new Exception("invalidSyntax", ['line' => $err->line, 'column' => $err->column]); + } + $body = (new \DOMXPath($d))->query("/opml/body"); + if ($body->length != 1) { + // not a valid OPML document + throw new Exception("invalidSemantics", ['type' => "OPML"]); + } + $body = $body->item(0); + // function to find the next node in the tree + $next = function(\DOMNode $node, bool $visitChildren = true) use ($body) { + if ($visitChildren && $node->hasChildNodes()) { + return $node->firstChild; + } elseif ($node->nextSibling) { + return $node->nextSibling; + } else { + while (!$node->nextSibling && !$node->isSameNode($body)) { + $node = $node->parentNode; + } + if (!$node->isSameNode($body)) { + return $node->nextSibling; + } else { + return null; + } + } + }; + $folders = []; + $feeds = []; + // add the root folder to a map from folder DOM nodes to folder ID numbers + $folderMap = new \SplObjectStorage; + $folderMap[$body] = sizeof($folderMap); + // iterate through each node in the body + $node = $body->firstChild; + while ($node) { + if ($node->nodeType == \XML_ELEMENT_NODE && $node->nodeName === "outline") { + // process any nodes which are outlines + if ($node->getAttribute("type") === "rss") { + // feed nodes + $url = $node->getAttribute("xmlUrl"); + $title = $node->getAttribute("text"); + $folder = $folderMap[$node->parentNode] ?? 0; + $categories = $node->getAttribute("category"); + if (strlen($categories)) { + // collapse and trim whitespace from category names, if any, splitting along commas + $categories = array_map(function($v) { + return trim(preg_replace("/\s+/", " ", $v)); + }, explode(",", $categories)); + // filter out any blank categories + $categories = array_filter($categories, function($v) { + return strlen($v); + }); + } else { + $categories = []; + } + $feeds[] = ['url' => $url, 'title' => $title, 'folder' => $folder, 'tags' => $categories]; + // skip any child nodes of a feed outline-entry + $node = $node->nextSibling ?: $node->parentNode; + } else { + // any outline entries which are not feeds are treated as folders + if (!$flat) { + // only process folders if we're not treating he file as flat + $id = sizeof($folderMap); + $folderMap[$node] = $id; + $folders[$id] = ['id' => $id, 'name' => $node->getAttribute("text"), 'parent' => $folderMap[$node->parentNode]]; + } + // proceed to child nodes, if any + $node = $next($node); + } + } else { + // skip any node which is not an outline element; if the node has descendents they are skipped as well + $node = $next($node, false); + } + } + return [$feeds, $folders]; + } + + public function export(string $user, bool $flat = false): string { + if (!Arsse::$user->exists($user)) { + throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); + } + $tags = []; + $folders = []; + $parents = [0 => null]; + // create a base document + $document = new \DOMDocument("1.0", "utf-8"); + $document->formatOutput = true; + $document->appendChild($document->createElement("opml")); + $document->documentElement->setAttribute("version", "2.0"); + $document->documentElement->appendChild($document->createElement("head")); + // create the "root folder" node (the body node, in OPML terms) + $folders[0] = $document->createElement("body"); + // begin a transaction for read isolation + $transaction = Arsse::$db->begin(); + // gather up the list of tags for each subscription + foreach (Arsse::$db->tagSummarize($user) as $r) { + $sub = $r['subscription']; + $tag = $r['name']; + // strip out any commas in the tag name; sadly this is lossy as OPML has no escape mechanism + $tag = str_replace(",", "", $tag); + if (!isset($tags[$sub])) { + $tags[$sub] = []; + } + $tags[$sub][] = $tag; + } + if (!$flat) { + // unless the output is requested flat, gather up the list of folders, using their database IDs as array indices + foreach (Arsse::$db->folderList($user) as $r) { + // note the index of its parent folder for later tree construction + $parents[$r['id']] = $r['parent'] ?? 0; + // create a DOM node for each folder; we don't insert it yet + $el = $document->createElement("outline"); + $el->setAttribute("text", $r['name']); + $folders[$r['id']] = $el; + } + } + // insert each folder into its parent node; for the root folder the parent is the document root node + foreach ($folders as $id => $el) { + $parent = $folders[$parents[$id]] ?? $document->documentElement; + $parent->appendChild($el); + } + // create a DOM node for each subscription and insert them directly into their folder DOM node + foreach (Arsse::$db->subscriptionList($user) as $r) { + $el = $document->createElement(("outline")); + $el->setAttribute("type", "rss"); + $el->setAttribute("text", $r['title']); + $el->setAttribute("xmlUrl", $r['url']); + // include the category attribute only if there are tags + if (isset($tags[$r['id']]) && sizeof($tags[$r['id']])) { + $el->setAttribute("category", implode(",", $tags[$r['id']])); + } + // if flat output was requested subscriptions are inserted into the root folder + ($folders[$r['folder'] ?? 0] ?? $folders[0])->appendChild($el); + } + // release the transaction + $transaction->rollback(); + // return the serialization + return $document->saveXML(); + } +} diff --git a/lib/Misc/Query.php b/lib/Misc/Query.php index d7a2c7f..55d2cac 100644 --- a/lib/Misc/Query.php +++ b/lib/Misc/Query.php @@ -13,13 +13,12 @@ class Query { protected $qCTE = []; // Common table expression query components protected $tCTE = []; // Common table expression type bindings protected $vCTE = []; // Common table expression binding values - protected $jCTE = []; // Common Table Expression joins - protected $qJoin = []; // JOIN clause components - protected $tJoin = []; // JOIN clause type bindings - protected $vJoin = []; // JOIN clause binding values protected $qWhere = []; // WHERE clause components protected $tWhere = []; // WHERE clause type bindings protected $vWhere = []; // WHERE clause binding values + protected $qWhereNot = []; // WHERE NOT clause components + protected $tWhereNot = []; // WHERE NOT clause type bindings + protected $vWhereNot = []; // WHERE NOT clause binding values protected $group = []; // GROUP BY clause components protected $order = []; // ORDER BY clause components protected $limit = 0; @@ -39,32 +38,29 @@ class Query { return true; } - public function setCTE(string $tableSpec, string $body, $types = null, $values = null, string $join = ''): bool { + public function setCTE(string $tableSpec, string $body, $types = null, $values = null): bool { $this->qCTE[] = "$tableSpec as ($body)"; if (!is_null($types)) { $this->tCTE[] = $types; $this->vCTE[] = $values; } - if (strlen($join)) { // the CTE might only participate in subqueries rather than a join on the main query - $this->jCTE[] = $join; - } return true; } - public function setJoin(string $join, $types = null, $values = null): bool { - $this->qJoin[] = $join; + public function setWhere(string $where, $types = null, $values = null): bool { + $this->qWhere[] = $where; if (!is_null($types)) { - $this->tJoin[] = $types; - $this->vJoin[] = $values; + $this->tWhere[] = $types; + $this->vWhere[] = $values; } return true; } - public function setWhere(string $where, $types = null, $values = null): bool { - $this->qWhere[] = $where; + public function setWhereNot(string $where, $types = null, $values = null): bool { + $this->qWhereNot[] = $where; if (!is_null($types)) { - $this->tWhere[] = $types; - $this->vWhere[] = $values; + $this->tWhereNot[] = $types; + $this->vWhereNot[] = $values; } return true; } @@ -76,12 +72,8 @@ class Query { return true; } - public function setOrder(string $order, bool $prepend = false): bool { - if ($prepend) { - array_unshift($this->order, $order); - } else { - $this->order[] = $order; - } + public function setOrder(string $order): bool { + $this->order[] = $order; return true; } @@ -91,25 +83,21 @@ class Query { return true; } - public function pushCTE(string $tableSpec, string $join = ''): bool { + public function pushCTE(string $tableSpec): bool { // this function takes the query body and converts it to a common table expression, putting it at the bottom of the existing CTE stack // all WHERE, ORDER BY, and LIMIT parts belong to the new CTE and are removed from the main query - $this->setCTE($tableSpec, $this->buildQueryBody(), [$this->tBody, $this->tWhere], [$this->vBody, $this->vWhere]); - $this->jCTE = []; + $this->setCTE($tableSpec, $this->buildQueryBody(), [$this->tBody, $this->tWhere, $this->tWhereNot], [$this->vBody, $this->vWhere, $this->vWhereNot]); $this->tBody = []; $this->vBody = []; $this->qWhere = []; $this->tWhere = []; $this->vWhere = []; - $this->qJoin = []; - $this->tJoin = []; - $this->vJoin = []; + $this->qWhereNot = []; + $this->tWhereNot = []; + $this->vWhereNot = []; $this->order = []; $this->group = []; $this->setLimit(0, 0); - if (strlen($join)) { - $this->jCTE[] = $join; - } return true; } @@ -129,52 +117,24 @@ class Query { } public function getTypes(): array { - return [$this->tCTE, $this->tBody, $this->tJoin, $this->tWhere]; + return [$this->tCTE, $this->tBody, $this->tWhere, $this->tWhereNot]; } public function getValues(): array { - return [$this->vCTE, $this->vBody, $this->vJoin, $this->vWhere]; - } - - public function getJoinTypes(): array { - return $this->tJoin; - } - - public function getJoinValues(): array { - return $this->vJoin; - } - - public function getWhereTypes(): array { - return $this->tWhere; - } - - public function getWhereValues(): array { - return $this->vWhere; - } - - public function getCTETypes(): array { - return $this->tCTE; - } - - public function getCTEValues(): array { - return $this->vCTE; + return [$this->vCTE, $this->vBody, $this->vWhere, $this->vWhereNot]; } protected function buildQueryBody(): string { $out = ""; // add the body $out .= $this->qBody; - if (sizeof($this->qCTE)) { - // add any joins against CTEs - $out .= " ".implode(" ", $this->jCTE); - } - // add any JOINs - if (sizeof($this->qJoin)) { - $out .= " ".implode(" ", $this->qJoin); - } // add any WHERE terms - if (sizeof($this->qWhere)) { - $out .= " WHERE ".implode(" AND ", $this->qWhere); + if (sizeof($this->qWhere) || sizeof($this->qWhereNot)) { + $where = implode(" AND ", $this->qWhere); + $whereNot = implode(" OR ", $this->qWhereNot); + $whereNot = strlen($whereNot) ? "NOT ($whereNot)" : ""; + $where = implode(" AND ", array_filter([$where, $whereNot])); + $out .= " WHERE $where"; } // add any GROUP BY terms if (sizeof($this->group)) { diff --git a/lib/REST.php b/lib/REST.php index 1bc395f..1ad740d 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -16,14 +16,12 @@ use Zend\Diactoros\Response\EmptyResponse; class REST { const API_LIST = [ - // NextCloud News version enumerator - 'ncn' => [ + 'ncn' => [ // NextCloud News version enumerator 'match' => '/index.php/apps/news/api', 'strip' => '/index.php/apps/news/api', 'class' => REST\NextCloudNews\Versions::class, ], - // NextCloud News v1-2 https://github.com/nextcloud/news/blob/master/docs/externalapi/Legacy.md - 'ncn_v1-2' => [ + 'ncn_v1-2' => [ // NextCloud News v1-2 https://github.com/nextcloud/news/blob/master/docs/externalapi/Legacy.md 'match' => '/index.php/apps/news/api/v1-2/', 'strip' => '/index.php/apps/news/api/v1-2', 'class' => REST\NextCloudNews\V1_2::class, @@ -38,19 +36,23 @@ class REST { 'strip' => '/tt-rss/feed-icons/', 'class' => REST\TinyTinyRSS\Icon::class, ], + 'fever' => [ // Fever https://web.archive.org/web/20161217042229/https://feedafever.com/api + 'match' => '/fever/', + 'strip' => '/fever/', + 'class' => REST\Fever\API::class, + ], // Other candidates: // Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html - // Fever https://feedafever.com/api // Feedbin v2 https://github.com/feedbin/feedbin-api // CommaFeed https://www.commafeed.com/api/ // Selfoss https://github.com/SSilence/selfoss/wiki/Restful-API-for-Apps-or-any-other-external-access + // NewsBlur http://www.newsblur.com/api // Unclear if clients exist: - // Miniflux https://github.com/miniflux/miniflux/blob/master/docs/json-rpc-api.markdown + // Miniflux https://docs.miniflux.app/en/latest/api.html#api-reference // NextCloud News v2 https://github.com/nextcloud/news/blob/master/docs/externalapi/External-Api.md // BirdReader https://github.com/glynnbird/birdreader/blob/master/API.md // Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9 // Proprietary (centralized) entities: - // NewsBlur http://www.newsblur.com/api // Feedly https://developer.feedly.com/ ]; const DEFAULT_PORTS = [ diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php new file mode 100644 index 0000000..83c3be8 --- /dev/null +++ b/lib/REST/Fever/API.php @@ -0,0 +1,415 @@ + V::T_STRING, // this parameter requires special handling + 'page' => V::T_INT, // parameter for hot links + 'range' => V::T_INT, // parameter for hot links + 'offset' => V::T_INT, // parameter for hot links + 'since_id' => V::T_INT, + 'max_id' => V::T_INT, + 'with_ids' => V::T_STRING, + 'group_ids' => V::T_STRING, // undocumented parameter for 'items' lookup + 'feed_ids' => V::T_STRING, // undocumented parameter for 'items' lookup + ]; + // POST parameters, all of which contain meaningful values + const PARAM_POST = [ + 'api_key' => V::T_STRING, + 'mark' => V::T_STRING, + 'as' => V::T_STRING, + 'id' => V::T_INT, + 'before' => V::T_DATE, + 'unread_recently_read' => V::T_BOOL, + ]; + + public function __construct() { + } + + public function dispatch(ServerRequestInterface $req): ResponseInterface { + $G = $this->normalizeInputGet($req->getQueryParams() ?? []); + $P = $this->normalizeInputPost($req->getParsedBody() ?? []); + if (!isset($G['api'])) { + // the original would have shown the Fever UI in the absence of the "api" parameter, but we'll return 404 + return new EmptyResponse(404); + } + switch ($req->getMethod()) { + case "OPTIONS": + return new EmptyResponse(204, [ + 'Allow' => "POST", + 'Accept' => "application/x-www-form-urlencoded", + ]); + case "POST": + if (strlen($req->getHeaderLine("Content-Type")) && $req->getHeaderLine("Content-Type") !== "application/x-www-form-urlencoded") { + return new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"]); + } + $out = [ + 'api_version' => self::LEVEL, + 'auth' => 0, + ]; + if ($req->getAttribute("authenticated", false)) { + // if HTTP authentication was successfully used, set the expected user ID + Arsse::$user->id = $req->getAttribute("authenticatedUser"); + $out['auth'] = 1; + } elseif (Arsse::$conf->userHTTPAuthRequired || Arsse::$conf->userPreAuth || $req->getAttribute("authenticationFailed", false)) { + // otherwise if HTTP authentication failed or is required, deny access at the HTTP level + return new EmptyResponse(401); + } + // produce a full response if authenticated or a basic response otherwise + if ($this->logIn(strtolower($P['api_key'] ?? ""))) { + $out = $this->processRequest($this->baseResponse(true), $G, $P); + } else { + $out = $this->baseResponse(false); + } + // return the result, possibly formatted as XML + return $this->formatResponse($out, ($G['api'] === "xml")); + default: + return new EmptyResponse(405, ['Allow' => "OPTIONS,POST"]); + } + } + + protected function normalizeInputGet(array $data): array { + $out = []; + if (array_key_exists("api", $data)) { + // the "api" parameter must be handled specially as it a string, but null has special meaning + $data['api'] = $data['api'] ?? "json"; + } + foreach (self::PARAM_BOOL as $p) { + // first handle all the boolean parameters + $out[$p] = array_key_exists($p, $data); + } + foreach (self::PARAM_GET as $p => $t) { + $out[$p] = V::normalize($data[$p] ?? null, $t | V::M_DROP, "unix"); + } + return $out; + } + + protected function normalizeInputPost(array $data): array { + $out = []; + foreach (self::PARAM_POST as $p => $t) { + $out[$p] = V::normalize($data[$p] ?? null, $t | V::M_DROP, "unix"); + } + return $out; + } + + protected function processRequest(array $out, array $G, array $P): array { + $listUnread = false; + $listSaved = false; + if ($P['unread_recently_read']) { + $this->setUnread(); + $listUnread = true; + } + if ($P['mark'] && $P['as'] && is_int($P['id'])) { + // depending on which mark are being made, + // either an 'unread_item_ids' or a + // 'saved_item_ids' entry will be added later + $listSaved = $this->setMarks($P, $listUnread); + } + if ($G['feeds'] || $G['groups']) { + if ($G['groups']) { + $out['groups'] = $this->getGroups(); + } + if ($G['feeds']) { + $out['feeds'] = $this->getFeeds(); + } + $out['feeds_groups'] = $this->getRelationships(); + } + if ($G['favicons']) { + // TODO: implement favicons properly + // we provide a single blank favicon for now + $out['favicons'] = [ + [ + 'id' => 0, + 'data' => self::GENERIC_ICON_TYPE.",".self::GENERIC_ICON_DATA, + ], + ]; + } + if ($G['items']) { + $out['items'] = $this->getItems($G); + $out['total_items'] = Arsse::$db->articleCount(Arsse::$user->id); + } + if ($G['links']) { + // TODO: implement hot links + $out['links'] = []; + } + if ($G['unread_item_ids'] || $listUnread) { + $out['unread_item_ids'] = $this->getItemIds((new Context)->unread(true)); + } + if ($G['saved_item_ids'] || $listSaved) { + $out['saved_item_ids'] = $this->getItemIds((new Context)->starred(true)); + } + return $out; + } + + protected function baseResponse(bool $authenticated): array { + $out = [ + 'api_version' => self::LEVEL, + 'auth' => (int) $authenticated, + ]; + if ($authenticated) { + // authenticated requests always include the most recent feed refresh + $out['last_refreshed_on_time'] = $this->getRefreshTime(); + } + return $out; + } + + protected function formatResponse(array $data, bool $xml): ResponseInterface { + if ($xml) { + $d = new \DOMDocument("1.0", "utf-8"); + $d->appendChild($this->makeXMLAssoc($data, $d->createElement("response"))); + return new XmlResponse($d->saveXML()); + } else { + return new JsonResponse($data, 200, [], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + } + } + + protected function makeXMLAssoc(array $data, \DOMElement $p): \DOMElement { + $d = $p->ownerDocument; + foreach ($data as $k => $v) { + if (!is_array($v)) { + $p->appendChild($d->createElement($k, (string) $v)); + } elseif (isset($v[0])) { + // this is a very simplistic check for an indexed array + // it would not pass muster in the face of generic data, + // but we'll assume our code produces only well-ordered + // indexed arrays + $p->appendChild($this->makeXMLIndexed($v, $d->createElement($k), substr($k, 0, strlen($k) - 1))); + } else { + $p->appendChild($this->makeXMLAssoc($v, $d->createElement($k))); + } + } + return $p; + } + + protected function makeXMLIndexed(array $data, \DOMElement $p, string $k): \DOMElement { + $d = $p->ownerDocument; + foreach ($data as $v) { + if (!is_array($v)) { + // this case is never encountered with Fever's output + $p->appendChild($d->createElement($k, (string) $v)); // @codeCoverageIgnore + } elseif (isset($v[0])) { + // this case is never encountered with Fever's output + $p->appendChild($this->makeXMLIndexed($v, $d->createElement($k), substr($k, 0, strlen($k) - 1))); // @codeCoverageIgnore + } else { + $p->appendChild($this->makeXMLAssoc($v, $d->createElement($k))); + } + } + return $p; + + } + + protected function logIn(string $hash): bool { + // if HTTP authentication was successful and sessions are not enforced, proceed unconditionally + if (isset(Arsse::$user->id) && !Arsse::$conf->userSessionEnforced) { + return true; + } + try { + // verify the supplied hash is valid + $s = Arsse::$db->TokenLookup("fever.login", $hash); + } catch (\JKingWeb\Arsse\Db\ExceptionInput $e) { + return false; + } + // set the user name + Arsse::$user->id = $s['user']; + return true; + } + + protected function setMarks(array $P, &$listUnread): bool { + $listSaved = false; + $c = new Context; + $id = $P['id']; + if ($P['before']) { + $c->notMarkedSince($P['before']); + } + switch ($P['mark']) { + case "item": + $c->article($id); + break; + case "group": + if ($id > 0) { + // concrete groups + $c->tag($id); + } elseif ($id < 0) { + // group negative-one is the "Sparks" supergroup i.e. no feeds + $c->not->folder(0); + } else { + // group zero is the "Kindling" supergroup i.e. all feeds + // nothing need to be done for this + } + break; + case "feed": + $c->subscription($id); + break; + default: + return $listSaved; + } + switch ($P['as']) { + case "read": + $data = ['read' => true]; + $listUnread = true; + break; + case "unread": + // this option is undocumented, but valid + $data = ['read' => false]; + $listUnread = true; + break; + case "saved": + $data = ['starred' => true]; + $listSaved = true; + break; + case "unsaved": + $data = ['starred' => false]; + $listSaved = true; + break; + default: + return $listSaved; + } + try { + Arsse::$db->articleMark(Arsse::$user->id, $data, $c); + } catch (ExceptionInput $e) { + // ignore any errors + } + return $listSaved; + } + + protected function setUnread() { + $lastUnread = Arsse::$db->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->getValue(); + if (!$lastUnread) { + // there are no articles + return; + } + // Fever takes the date of the last read article less fifteen seconds as a cut-off. + // We take the date of last mark (whether it be read, unread, saved, unsaved), which + // may not actually signify a mark, but we'll otherwise also count back fifteen seconds + $c = new Context; + $lastUnread = Date::normalize($lastUnread, "sql"); + $since = Date::sub("PT15S", $lastUnread); + $c->unread(false)->markedSince($since); + Arsse::$db->articleMark(Arsse::$user->id, ['read' => false], $c); + } + + protected function getRefreshTime() { + return Date::transform(Arsse::$db->subscriptionRefreshed(Arsse::$user->id), "unix"); + } + + protected function getFeeds(): array { + $out = []; + foreach (arsse::$db->subscriptionList(Arsse::$user->id) as $sub) { + $out[] = [ + 'id' => (int) $sub['id'], + 'favicon_id' => 0, // TODO: implement favicons + 'title' => (string) $sub['title'], + 'url' => $sub['url'], + 'site_url' => $sub['source'], + 'is_spark' => 0, + 'last_updated_on_time' => Date::transform($sub['edited'], "unix", "sql"), + ]; + } + return $out; + } + + protected function getGroups(): array { + $out = []; + foreach (Arsse::$db->tagList(Arsse::$user->id) as $member) { + $out[] = [ + 'id' => (int) $member['id'], + 'title' => $member['name'], + ]; + } + return $out; + } + + protected function getRelationships(): array { + $out = []; + $sets = []; + foreach (Arsse::$db->tagSummarize(Arsse::$user->id) as $member) { + if (!isset($sets[$member['id']])) { + $sets[$member['id']] = []; + } + $sets[$member['id']][] = (int) $member['subscription']; + } + foreach ($sets as $id => $subs) { + $out[] = [ + 'group_id' => (int) $id, + 'feed_ids' => implode(",", $subs), + ]; + } + return $out; + } + + protected function getItems(array $G): array { + $c = (new Context)->limit(50); + $reverse = false; + // handle the standard options + if ($G['with_ids']) { + $c->articles(explode(",", $G['with_ids'])); + } elseif ($G['max_id']) { + $c->latestArticle($G['max_id'] - 1); + $reverse = true; + } elseif ($G['since_id']) { + $c->oldestArticle($G['since_id'] + 1); + } + // handle the undocumented options + if ($G['group_ids']) { + $c->tags(explode(",", $G['group_ids'])); + } + if ($G['feed_ids']) { + $c->subscriptions(explode(",", $G['feed_ids'])); + } + // get results + $out = []; + $order = $reverse ? "id desc" : "id"; + foreach (Arsse::$db->articleList(Arsse::$user->id, $c, ["id", "subscription", "title", "author", "content", "url", "starred", "unread", "published_date"], [$order]) as $r) { + $out[] = [ + 'id' => (int) $r['id'], + 'feed_id' => (int) $r['subscription'], + 'title' => (string) $r['title'], + 'author' => (string) $r['author'], + 'html' => (string) $r['content'], + 'url' => (string) $r['url'], + 'is_saved' => (int) $r['starred'], + 'is_read' => (int) !$r['unread'], + 'created_on_time' => Date::transform($r['published_date'], "unix", "sql"), + ]; + } + return $out; + } + + protected function getItemIds(Context $c = null): string { + $out = []; + foreach (Arsse::$db->articleList(Arsse::$user->id, $c) as $r) { + $out[] = (int) $r['id']; + } + return implode(",", $out); + } +} diff --git a/lib/REST/Fever/User.php b/lib/REST/Fever/User.php new file mode 100644 index 0000000..b702ae4 --- /dev/null +++ b/lib/REST/Fever/User.php @@ -0,0 +1,34 @@ +generatePassword(); + $hash = md5("$user:$password"); + $tr = Arsse::$db->begin(); + Arsse::$db->tokenRevoke($user, "fever.login"); + Arsse::$db->tokenCreate($user, "fever.login", $hash); + $tr->commit(); + return $password; + } + + public function unregister(string $user): bool { + return (bool) Arsse::$db->tokenRevoke($user, "fever.login"); + } + + public function authenticate(string $user, string $password): bool { + try { + return (bool) Arsse::$db->tokenLookup("fever.login", md5("$user:$password")); + } catch (ExceptionInput $e) { + return false; + } + } +} diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 84beda3..0df5032 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -10,7 +10,7 @@ use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; @@ -521,14 +521,10 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { $c->limit($data['batchSize']); } // set the order of returned items - if ($data['oldestFirst']) { - $c->reverse(false); - } else { - $c->reverse(true); - } + $reverse = !$data['oldestFirst']; // set the edition mark-off; the database uses an or-equal comparison for internal consistency, but the protocol does not, so we must adjust by one if ($data['offset'] > 0) { - if ($c->reverse) { + if ($reverse) { $c->latestEdition($data['offset'] - 1); } else { $c->oldestEdition($data['offset'] + 1); @@ -579,7 +575,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { "starred", "modified_date", "fingerprint", - ]); + ], [$reverse ? "edition desc" : "edition"]); } 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 4ddea6f..045710c 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -8,11 +8,10 @@ namespace JKingWeb\Arsse\REST\TinyTinyRSS; use JKingWeb\Arsse\Feed; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Database; -use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; +use JKingWeb\Arsse\Database; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\Date; -use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\ExceptionType; @@ -49,7 +48,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'sid' => ValueInfo::T_STRING, // session ID 'seq' => ValueInfo::T_INT, // request number from client 'user' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // user name for `login` - 'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // password for `login` and `subscribeToFeed` + 'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // password for `login` or remote password for `subscribeToFeed` 'include_empty' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include empty items in `getFeedTree` and `getCategories` 'unread_only' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to exclude items without unread articles in `getCategories` and `getFeeds` 'enable_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to NOT show subcategories in `getCategories @@ -76,7 +75,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'since_id' => ValueInfo::T_INT, // cut-off article ID for `getHeadlines` and `getCompactHeadlines; returns only higher article IDs when specified 'order_by' => ValueInfo::T_STRING, // sort order for `getHeadlines` 'include_header' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to attach a header to the results of `getHeadlines` - 'search' => ValueInfo::T_STRING, // search string for `getHeadlines` (not yet implemented) + 'search' => ValueInfo::T_STRING, // search string for `getHeadlines` 'field' => ValueInfo::T_INT, // which state to change in `updateArticle` 'mode' => ValueInfo::T_INT, // whether to set, clear, or toggle the selected state in `updateArticle` 'data' => ValueInfo::T_STRING, // note text in `updateArticle` if setting a note @@ -1017,6 +1016,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $label = $this->labelIn($data['label_id']); $articles = explode(",", (string) $data['article_ids']); $assign = $data['assign'] ?? false; + $assign = $assign ? Database::ASSOC_ADD : Database::ASSOC_REMOVE; $out = 0; $in = array_chunk($articles, 50); for ($a = 0; $a < sizeof($in); $a++) { @@ -1024,7 +1024,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $c = new Context; $c->articles($in[$a]); try { - $out += Arsse::$db->labelArticlesSet(Arsse::$user->id, $label, $c, !$assign); + $out += Arsse::$db->labelArticlesSet(Arsse::$user->id, $label, $c, $assign); } catch (ExceptionInput $e) { } } @@ -1286,6 +1286,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { "id", "guid", "title", + "author", "url", "unread", "starred", @@ -1437,7 +1438,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // no context needed here break; case self::FEED_READ: - $c->markedSince(Date::sub("PT24H"))->unread(false); // FIXME: this selects any recently touched article which is read, not necessarily a recently read one + $c->markedSince(Date::sub("PT24H"))->unread(false); // FIXME: this selects any recently touched (read, starred, annotated) article which is read, not necessarily a recently read one break; default: // any actual feed @@ -1478,20 +1479,27 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { default: throw new \JKingWeb\Arsse\Exception("constantUnknown", $viewMode); // @codeCoverageIgnore } - // TODO: implement searching + // handle the search string, if any + if (isset($data['search'])) { + $c = Search::parse($data['search'], $c); + if (!$c) { + // the search string inherently returns an empty result, either directly or interacting with other input + return new ResultEmpty; + } + } // handle sorting switch ($data['order_by']) { case "date_reverse": // sort oldest first - $c->reverse(false); + $order = ["edited_date"]; break; case "feed_dates": // sort newest first - $c->reverse(true); + $order = ["edited_date desc"]; break; default: - // in TT-RSS the default sort order is unusual for some of the special feeds; we do not implement this - $c->reverse(true); + // sort most recently marked for special feeds, newest first otherwise + $order = (!$cat && ($id == self::FEED_READ || $id == self::FEED_STARRED)) ? ["marked_date desc"] : ["edited_date desc"]; break; } // set the limit and offset @@ -1506,6 +1514,6 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $c->oldestArticle($data['since_id'] + 1); } // return results - return Arsse::$db->articleList(Arsse::$user->id, $c, $fields); + return Arsse::$db->articleList(Arsse::$user->id, $c, $fields, $order); } } diff --git a/lib/REST/TinyTinyRSS/Search.php b/lib/REST/TinyTinyRSS/Search.php new file mode 100644 index 0000000..f791361 --- /dev/null +++ b/lib/REST/TinyTinyRSS/Search.php @@ -0,0 +1,367 @@ + "unread", + "star" => "starred", + "note" => "annotated", + "pub" => "published", // TODO: not implemented + ]; + const FIELDS_TEXT = [ + "title" => "titleTerms", + "author" => "authorTerms", + "note" => "annotationTerms", + "" => "searchTerms", + ]; + + public static function parse(string $search, Context $context = null) { + // normalize the input + $search = strtolower(trim(preg_replace("<\s+>", " ", $search))); + // set initial state + $tokens = []; + $pos = -1; + $stop = strlen($search); + $state = self::STATE_BEFORE_TOKEN; + $buffer = ""; + $tag = ""; + $flag_negative = false; + $context = $context ?? new Context; + // process + try { + while (++$pos <= $stop) { + $char = @$search[$pos]; + switch ($state) { + case self::STATE_BEFORE_TOKEN: + switch ($char) { + case "": + continue 3; + case " ": + continue 3; + case '"': + if ($flag_negative) { + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG; + } else { + $state = self::STATE_BEFORE_TOKEN_QUOTED; + } + continue 3; + case "-": + if (!$flag_negative) { + $flag_negative = true; + } else { + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG; + } + continue 3; + case "@": + $state = self::STATE_IN_DATE; + continue 3; + case ":": + $state = self::STATE_IN_TOKEN; + continue 3; + default: + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG; + continue 3; + } + // no break + case self::STATE_BEFORE_TOKEN_QUOTED: + switch ($char) { + case "": + continue 3; + case '"': + if (($pos + 1 == $stop) || $search[$pos + 1] === " ") { + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; + } else { + $state = self::STATE_IN_TOKEN_OR_TAG; + } + continue 3; + case "\\": + if ($pos + 1 == $stop) { + $buffer .= $char; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $buffer .= $char; + } + $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; + continue 3; + case "-": + if (!$flag_negative) { + $flag_negative = true; + } else { + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; + } + continue 3; + case "@": + $state = self::STATE_IN_DATE_QUOTED; + continue 3; + case ":": + $state = self::STATE_IN_TOKEN_QUOTED; + continue 3; + default: + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; + continue 3; + } + // no break + case self::STATE_IN_DATE: + while ($pos < $stop && $search[$pos] !== " ") { + $buffer .= $search[$pos++]; + } + $context = self::processToken($context, $buffer, $tag, $flag_negative, true); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + continue 2; + case self::STATE_IN_DATE_QUOTED: + switch ($char) { + case "": + case '"': + if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { + $context = self::processToken($context, $buffer, $tag, $flag_negative, true); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $state = self::STATE_IN_DATE; + } + continue 3; + case "\\": + if ($pos + 1 == $stop) { + $buffer .= $char; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $buffer .= $char; + } + continue 3; + default: + $buffer .= $char; + continue 3; + } + // no break + case self::STATE_IN_TOKEN: + while ($pos < $stop && $search[$pos] !== " ") { + $buffer .= $search[$pos++]; + } + if (!strlen($tag)) { + $buffer = ":".$buffer; + } + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + continue 2; + case self::STATE_IN_TOKEN_QUOTED: + switch ($char) { + case "": + case '"': + if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { + if (!strlen($tag)) { + $buffer = ":".$buffer; + } + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $state = self::STATE_IN_TOKEN; + } + continue 3; + case "\\": + if ($pos + 1 == $stop) { + $buffer .= $char; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $buffer .= $char; + } + continue 3; + default: + $buffer .= $char; + continue 3; + } + // no break + case self::STATE_IN_TOKEN_OR_TAG: + switch ($char) { + case "": + case " ": + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + continue 3; + case ":": + $tag = $buffer; + $buffer = ""; + $state = self::STATE_IN_TOKEN; + continue 3; + default: + $buffer .= $char; + continue 3; + } + // no break + case self::STATE_IN_TOKEN_OR_TAG_QUOTED: + switch ($char) { + case "": + case '"': + if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $state = self::STATE_IN_TOKEN_OR_TAG; + } + continue 3; + case "\\": + if ($pos + 1 == $stop) { + $buffer .= $char; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $buffer .= $char; + } + continue 3; + case ":": + $tag = $buffer; + $buffer = ""; + $state = self::STATE_IN_TOKEN_QUOTED; + continue 3; + default: + $buffer .= $char; + continue 3; + } + // no break + default: + throw new \Exception; // @codeCoverageIgnore + } + } + } catch (Exception $e) { + return null; + } + return $context; + } + + protected static function processToken(Context $c, string $value, string $tag, bool $neg, bool $date): Context { + if (!strlen($value) && !strlen($tag)) { + return $c; + } elseif (!strlen($value)) { + // if a tag has an empty value, the tag is treated as a search term instead + $value = "$tag:"; + $tag = ""; + } + if ($date) { + return self::setDate($value, $c, $neg); + } elseif (isset(self::FIELDS_BOOLEAN[$tag])) { + return self::setBoolean($tag, $value, $c, $neg); + } else { + return self::addTerm($tag, $value, $c, $neg); + } + } + + protected static function addTerm(string $tag, string $value, Context $c, bool $neg): Context { + $c = $neg ? $c->not : $c; + $type = self::FIELDS_TEXT[$tag] ?? ""; + if (!$type) { + $value = "$tag:$value"; + $type = self::FIELDS_TEXT[""]; + } + return $c->$type(array_merge($c->$type ?? [], [$value])); + } + + protected static function setDate(string $value, Context $c, bool $neg): Context { + $spec = Date::normalize($value); + // TTRSS treats invalid dates as the start of the Unix epoch; we ignore them instead + if (!$spec) { + return $c; + } + $day = $spec->format("Y-m-d"); + $start = $day."T00:00:00+00:00"; + $end = $day."T23:59:59+00:00"; + // if a date is already set, the same date is a no-op; anything else is a contradiction + $cc = $neg ? $c->not : $c; + if ($cc->modifiedSince() || $cc->notModifiedSince()) { + if (!$cc->modifiedSince() || !$cc->notModifiedSince() || $cc->modifiedSince->format("c") !== $start || $cc->notModifiedSince->format("c") !== $end) { + // FIXME: multiple negative dates should be allowed, but the design of the Context class does not support this + throw new Exception; + } else { + return $c; + } + } + $cc->modifiedSince($start); + $cc->notModifiedSince($end); + return $c; + } + + protected static function setBoolean(string $tag, string $value, Context $c, bool $neg): Context { + $set = ["true" => true, "false" => false][$value] ?? null; + if (is_null($set)) { + return self::addTerm($tag, $value, $c, $neg); + } else { + // apply negation + $set = $neg ? !$set : $set; + if ($tag === "pub") { + // TODO: this needs to be implemented correctly if the Published feed is implemented + // currently specifying true will always yield an empty result (nothing is ever published), and specifying false is a no-op (matches everything) + if ($set) { + throw new Exception; + } else { + return $c; + } + } else { + $field = (self::FIELDS_BOOLEAN[$tag] ?? ""); + if (!$c->$field()) { + // field has not yet been set; set it + return $c->$field($set); + } elseif ($c->$field == $set) { + // field is already set to same value; do nothing + return $c; + } else { + // contradiction: query would return no results + throw new Exception; + } + } + } + } +} diff --git a/lib/Service.php b/lib/Service.php index bc752ae..93d4e9b 100644 --- a/lib/Service.php +++ b/lib/Service.php @@ -92,7 +92,12 @@ class Service { } public static function cleanupPost(): bool { - // delete old articles, according to configured threasholds - return Arsse::$db->articleCleanup(); + // delete old articles, according to configured thresholds + $deleted = Arsse::$db->articleCleanup(); + // if any articles were deleted, perform database maintenance + if ($deleted) { + Arsse::$db->driverMaintenance(); + } + return true; } } diff --git a/lib/User.php b/lib/User.php index d7aae1c..691d6fa 100644 --- a/lib/User.php +++ b/lib/User.php @@ -110,11 +110,28 @@ class User { if (Arsse::$db->userExists($user)) { // if the password change was successful and the user exists, set the internal password to the same value Arsse::$db->userPasswordSet($user, $out); + // also invalidate any current sessions for the user + Arsse::$db->sessionDestroy($user); } return $out; } - protected function generatePassword(): string { + public function passwordUnset(string $user, $oldPassword = null): bool { + $func = "userPasswordUnset"; + if (!$this->authorize($user, $func)) { + throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]); + } + $out = $this->u->userPasswordUnset($user, $oldPassword); + if (Arsse::$db->userExists($user)) { + // if the password change was successful and the user exists, set the internal password to the same value + Arsse::$db->userPasswordSet($user, null); + // also invalidate any current sessions for the user + Arsse::$db->sessionDestroy($user); + } + return $out; + } + + public function generatePassword(): string { return (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get(); } } diff --git a/lib/User/Driver.php b/lib/User/Driver.php index 50ef8f3..b5657ac 100644 --- a/lib/User/Driver.php +++ b/lib/User/Driver.php @@ -29,4 +29,6 @@ interface Driver { public function userList(): array; // sets a user's password; if the driver does not require the old password, it may be ignored public function userPasswordSet(string $user, string $newPassword = null, string $oldPassword = null); + // removes a user's password; this makes authentication fail unconditionally + public function userPasswordUnset(string $user, string $oldPassword = null): bool; } diff --git a/lib/User/Internal/Driver.php b/lib/User/Internal/Driver.php index 4c73025..d50777a 100644 --- a/lib/User/Internal/Driver.php +++ b/lib/User/Internal/Driver.php @@ -20,6 +20,9 @@ class Driver implements \JKingWeb\Arsse\User\Driver { public function auth(string $user, string $password): bool { try { $hash = $this->userPasswordGet($user); + if (is_null($hash)) { + return false; + } } catch (Exception $e) { return false; } @@ -58,7 +61,17 @@ class Driver implements \JKingWeb\Arsse\User\Driver { return $newPassword; } - protected function userPasswordGet(string $user): string { + public function userPasswordUnset(string $user, string $oldPassword = null): bool { + // do nothing: the internal database is updated regardless of what the driver does (assuming it does not throw an exception) + // throw an exception if the user does not exist + if (!$this->userExists($user)) { + throw new Exception("doesNotExist", ['action' => "userPasswordUnset", 'user' => $user]); + } else { + return true; + } + } + + protected function userPasswordGet(string $user) { return Arsse::$db->userPasswordGet($user); } } diff --git a/locale/en.php b/locale/en.php index f576442..e095db8 100644 --- a/locale/en.php +++ b/locale/en.php @@ -36,6 +36,8 @@ return [ 'Exception.JKingWeb/Arsse/Exception.unknown' => 'An unknown error has occurred', // indicates programming error 'Exception.JKingWeb/Arsse/Exception.constantUnknown' => 'Supplied constant value ({0}) is unknown or invalid in the context in which it was used', + // indicates programming error + 'Exception.JKingWeb/Arsse/Exception.arrayEmpty' => 'Supplied array "{0}" is empty, but should have at least one element', 'Exception.JKingWeb/Arsse/ExceptionType.strictFailure' => 'Supplied value could not be normalized to {0, select, 1 {null} 2 {boolean} @@ -118,6 +120,7 @@ return [ 'Exception.JKingWeb/Arsse/Db/Exception.savepointStale' => 'Tried to {action} stale savepoint {index}', // indicates programming error 'Exception.JKingWeb/Arsse/Db/Exception.resultReused' => 'Result set already iterated', + 'Exception.JKingWeb/Arsse/Db/ExceptionRetry.schemaChange' => '{0}', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.missing' => 'Required field "{field}" missing while performing action "{action}"', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.whitespace' => 'Field "{field}" of action "{action}" may not contain only whitespace', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.tooLong' => 'Field "{field}" of action "{action}" has a maximum length of {max}', @@ -152,4 +155,13 @@ return [ 'Exception.JKingWeb/Arsse/Feed/Exception.xmlEntity' => 'Refused to parse feed "{url}" because it contains an XXE attack', 'Exception.JKingWeb/Arsse/Feed/Exception.subscriptionNotFound' => 'Unable to find a feed at location "{url}"', 'Exception.JKingWeb/Arsse/Feed/Exception.unsupportedFeedFormat' => 'Feed "{url}" is of an unsupported format', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileMissing' => 'Import {type} file "{file}" does not exist', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUnreadable' => 'Insufficient permissions to read {type} file "{file}" for import', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUncreatable' => 'Insufficient permissions to write {type} export to file "{file}"', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUnwritable' => 'Insufficient permissions to write {type} export to existing file "{file}"', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidSyntax' => 'Input data syntax error at line {line}, column {column}', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidSemantics' => 'Input data is not valid {type} data', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidFolderName' => 'Input data contains an invalid folder name', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidFolderCopy' => 'Input data contains multiple folders of the same name under the same parent', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidTagName' => 'Input data contains an invalid tag name', ]; diff --git a/robo b/robo index 7d4d4d7..f525941 100755 --- a/robo +++ b/robo @@ -1,10 +1,11 @@ #! /bin/sh base=`dirname "$0"` roboCommand="$1" - shift -if [ "$1" == "clean" ]; then - "$base/vendor/bin/robo" "$roboCommand" $* + +ulimit -n 2048 +if [ "$1" = "clean" ]; then + "$base/vendor/bin/robo" "$roboCommand" "$@" else - "$base/vendor/bin/robo" "$roboCommand" -- $* -fi \ No newline at end of file + "$base/vendor/bin/robo" "$roboCommand" -- "$@" +fi diff --git a/sql/MySQL/0.sql b/sql/MySQL/0.sql index f6b00e2..3901ad7 100644 --- a/sql/MySQL/0.sql +++ b/sql/MySQL/0.sql @@ -5,7 +5,7 @@ -- Please consult the SQLite 3 schemata for commented version create table arsse_meta( - `key` varchar(255) primary key, + "key" varchar(255) primary key, value longtext ) character set utf8mb4; @@ -21,9 +21,9 @@ create table arsse_users( create table arsse_users_meta( owner varchar(255) not null references arsse_users(id) on delete cascade on update cascade, - `key` varchar(255) not null, + "key" varchar(255) not null, value varchar(255), - primary key(owner,`key`) + primary key(owner,"key") ) character set utf8mb4; create table arsse_folders( @@ -93,7 +93,7 @@ create table arsse_enclosures( 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` boolean not null default 0, + "read" boolean not null default 0, starred boolean not null default 0, modified datetime(0) not null default CURRENT_TIMESTAMP, primary key(article,subscription) @@ -110,4 +110,4 @@ create table arsse_categories( name varchar(255) ) character set utf8mb4; -insert into arsse_meta(`key`,value) values('schema_version','1'); +insert into arsse_meta("key",value) values('schema_version','1'); diff --git a/sql/MySQL/1.sql b/sql/MySQL/1.sql index eb4ce5f..f7a8542 100644 --- a/sql/MySQL/1.sql +++ b/sql/MySQL/1.sql @@ -8,7 +8,7 @@ create table arsse_sessions ( id varchar(255) primary key, created datetime(0) not null default CURRENT_TIMESTAMP, expires datetime(0) not null, - `user` varchar(255) not null references arsse_users(id) on delete cascade on update cascade + "user" varchar(255) not null references arsse_users(id) on delete cascade on update cascade ) character set utf8mb4; create table arsse_labels ( @@ -30,4 +30,4 @@ create table arsse_label_members ( alter table arsse_marks add column note longtext; -update arsse_meta set value = '2' where `key` = 'schema_version'; +update arsse_meta set value = '2' where "key" = 'schema_version'; diff --git a/sql/MySQL/2.sql b/sql/MySQL/2.sql index d63cbb6..feaf4c9 100644 --- a/sql/MySQL/2.sql +++ b/sql/MySQL/2.sql @@ -20,4 +20,4 @@ alter table arsse_articles convert to character set utf8mb4 collate utf8mb4_unic alter table arsse_categories convert to character set utf8mb4 collate utf8mb4_unicode_ci; alter table arsse_labels convert to character set utf8mb4 collate utf8mb4_unicode_ci; -update arsse_meta set value = '3' where `key` = 'schema_version'; +update arsse_meta set value = '3' where "key" = 'schema_version'; diff --git a/sql/MySQL/3.sql b/sql/MySQL/3.sql index c02df03..32f87a6 100644 --- a/sql/MySQL/3.sql +++ b/sql/MySQL/3.sql @@ -7,4 +7,4 @@ alter table arsse_marks change column modified modified datetime(0); alter table arsse_marks add column touched boolean not null default 0; -update arsse_meta set value = '4' where `key` = 'schema_version'; +update arsse_meta set value = '4' where "key" = 'schema_version'; diff --git a/sql/MySQL/4.sql b/sql/MySQL/4.sql new file mode 100644 index 0000000..bde1212 --- /dev/null +++ b/sql/MySQL/4.sql @@ -0,0 +1,41 @@ +-- 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_tags( + id serial primary key, + owner varchar(255) not null references arsse_users(id) on delete cascade on update cascade, + name varchar(255) not null, + modified datetime(0) not null default CURRENT_TIMESTAMP, + unique(owner,name) +) character set utf8mb4 collate utf8mb4_unicode_ci; + +create table arsse_tag_members( + tag bigint not null references arsse_tags(id) on delete cascade, + subscription bigint not null references arsse_subscriptions(id) on delete cascade, + assigned boolean not null default 1, + modified datetime(0) not null default CURRENT_TIMESTAMP, + primary key(tag,subscription) +) character set utf8mb4 collate utf8mb4_unicode_ci; + +create table arsse_tokens( + id varchar(255) not null, + class varchar(255) not null, + "user" varchar(255) not null references arsse_users(id) on delete cascade on update cascade, + created datetime(0) not null default CURRENT_TIMESTAMP, + expires datetime(0), + primary key(id,class) +) character set utf8mb4 collate utf8mb4_unicode_ci; + +alter table arsse_users drop column name; +alter table arsse_users drop column avatar_type; +alter table arsse_users drop column avatar_data; +alter table arsse_users drop column admin; +alter table arsse_users drop column rights; + +drop table arsse_users_meta; + + +update arsse_meta set value = '5' where "key" = 'schema_version'; diff --git a/sql/PostgreSQL/0.sql b/sql/PostgreSQL/0.sql index 3d940f5..d11c2c4 100644 --- a/sql/PostgreSQL/0.sql +++ b/sql/PostgreSQL/0.sql @@ -110,4 +110,4 @@ create table arsse_categories( name text ); -insert into arsse_meta(key,value) values('schema_version','1'); +insert into arsse_meta("key",value) values('schema_version','1'); diff --git a/sql/PostgreSQL/1.sql b/sql/PostgreSQL/1.sql index 1549fd5..d2a5480 100644 --- a/sql/PostgreSQL/1.sql +++ b/sql/PostgreSQL/1.sql @@ -30,4 +30,4 @@ create table arsse_label_members ( alter table arsse_marks add column note text not null default ''; -update arsse_meta set value = '2' where key = 'schema_version'; +update arsse_meta set value = '2' where "key" = 'schema_version'; diff --git a/sql/PostgreSQL/2.sql b/sql/PostgreSQL/2.sql index 847edb7..33863fb 100644 --- a/sql/PostgreSQL/2.sql +++ b/sql/PostgreSQL/2.sql @@ -13,4 +13,4 @@ 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'; +update arsse_meta set value = '3' where "key" = 'schema_version'; diff --git a/sql/PostgreSQL/3.sql b/sql/PostgreSQL/3.sql index 2290ae5..091cf46 100644 --- a/sql/PostgreSQL/3.sql +++ b/sql/PostgreSQL/3.sql @@ -8,4 +8,4 @@ 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'; +update arsse_meta set value = '4' where "key" = 'schema_version'; diff --git a/sql/PostgreSQL/4.sql b/sql/PostgreSQL/4.sql new file mode 100644 index 0000000..6096211 --- /dev/null +++ b/sql/PostgreSQL/4.sql @@ -0,0 +1,40 @@ +-- 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_tags( + id bigserial primary key, + owner text not null references arsse_users(id) on delete cascade on update cascade, + name text not null collate "und-x-icu", + modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP, + unique(owner,name) +); + +create table arsse_tag_members( + tag bigint not null references arsse_tags(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(tag,subscription) +); + +create table arsse_tokens( + id text, + class text not null, + "user" text not null references arsse_users(id) on delete cascade on update cascade, + created timestamp(0) without time zone not null default CURRENT_TIMESTAMP, + expires timestamp(0) without time zone, + primary key(id,class) +); + +alter table arsse_users drop column name; +alter table arsse_users drop column avatar_type; +alter table arsse_users drop column avatar_data; +alter table arsse_users drop column admin; +alter table arsse_users drop column rights; + +drop table arsse_users_meta; + +update arsse_meta set value = '5' where "key" = 'schema_version'; diff --git a/sql/SQLite3/0.sql b/sql/SQLite3/0.sql index 7a9dea6..d9a9b9f 100644 --- a/sql/SQLite3/0.sql +++ b/sql/SQLite3/0.sql @@ -2,9 +2,6 @@ -- Copyright 2017 J. King, Dustin Wilson et al. -- See LICENSE and AUTHORS files for details --- Make the database WAL-journalled; this is persitent -PRAGMA journal_mode = wal; - create table arsse_meta( -- application metadata key text primary key not null, -- metadata key @@ -130,4 +127,4 @@ create table arsse_categories( -- set version marker pragma user_version = 1; -insert into arsse_meta(key,value) values('schema_version','1'); +insert into arsse_meta("key",value) values('schema_version','1'); diff --git a/sql/SQLite3/1.sql b/sql/SQLite3/1.sql index dc7862d..7f213e1 100644 --- a/sql/SQLite3/1.sql +++ b/sql/SQLite3/1.sql @@ -5,8 +5,8 @@ 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 + 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; @@ -20,7 +20,7 @@ create table arsse_labels( ); create table arsse_label_members( --- uabels assignments for articles +-- label 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 @@ -48,4 +48,4 @@ alter table arsse_marks_new rename to arsse_marks; -- set version marker pragma user_version = 2; -update arsse_meta set value = '2' where key = 'schema_version'; +update arsse_meta set value = '2' where "key" = 'schema_version'; diff --git a/sql/SQLite3/2.sql b/sql/SQLite3/2.sql index b378467..14a253d 100644 --- a/sql/SQLite3/2.sql +++ b/sql/SQLite3/2.sql @@ -121,4 +121,4 @@ alter table arsse_labels_new rename to arsse_labels; -- set version marker pragma user_version = 3; -update arsse_meta set value = '3' where key = 'schema_version'; +update arsse_meta set value = '3' where "key" = 'schema_version'; diff --git a/sql/SQLite3/3.sql b/sql/SQLite3/3.sql index 0d58324..087bf32 100644 --- a/sql/SQLite3/3.sql +++ b/sql/SQLite3/3.sql @@ -24,4 +24,4 @@ reindex nocase; -- set version marker pragma user_version = 4; -update arsse_meta set value = '4' where key = 'schema_version'; +update arsse_meta set value = '4' where "key" = 'schema_version'; diff --git a/sql/SQLite3/4.sql b/sql/SQLite3/4.sql new file mode 100644 index 0000000..f7cdd20 --- /dev/null +++ b/sql/SQLite3/4.sql @@ -0,0 +1,78 @@ +-- SPDX-License-Identifier: MIT +-- Copyright 2017 J. King, Dustin Wilson et al. +-- See LICENSE and AUTHORS files for details + +create table arsse_tags( +-- user-defined subscription tags + 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, -- tag text + modified text not null default CURRENT_TIMESTAMP, -- time at which the tag was last modified + unique(owner,name) +); + +create table arsse_tag_members( +-- tag assignments for subscriptions + tag integer not null references arsse_tags(id) on delete cascade, -- tag ID associated to a subscription + subscription integer not null references arsse_subscriptions(id) on delete cascade, -- Subscription associated to a tag + 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(tag,subscription) -- only one association of a given tag to a given subscription +) without rowid; + +create table arsse_tokens( +-- access tokens that are managed by the protocol handler and may optionally expire + id text, -- token identifier + class text not null, -- symbolic name of the protocol handler managing the token + user text not null references arsse_users(id) on delete cascade on update cascade, -- user associated with the token + created text not null default CURRENT_TIMESTAMP, -- creation timestamp + expires text, -- time at which token is no longer valid + primary key(id,class) -- tokens must be unique for their class +) without rowid; + + +-- clean up the user tables to remove unused stuff +-- if any of the removed things are implemented in future, necessary structures will be added back in at that time + +create table arsse_users_new( +-- 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 +) without rowid; +insert into arsse_users_new select id,password from arsse_users; +drop table arsse_users; +alter table arsse_users_new rename to arsse_users; + +drop table arsse_users_meta; + + +-- use WITHOUT ROWID tables when possible; this is an SQLite-specific change + +create table arsse_meta_new( +-- application metadata + key text primary key not null, -- metadata key + value text -- metadata value, serialized as a string +) without rowid; +insert into arsse_meta_new select * from arsse_meta; +drop table arsse_meta; +alter table arsse_meta_new rename to arsse_meta; + +create table arsse_marks_new( +-- 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 +) without rowid; +insert into arsse_marks_new select * from arsse_marks; +drop table arsse_marks; +alter table arsse_marks_new rename to arsse_marks; + + +-- set version marker +pragma user_version = 5; +update arsse_meta set value = '5' where "key" = 'schema_version'; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 59c04a1..68c7ea8 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -8,6 +8,7 @@ namespace JKingWeb\Arsse; const NS_BASE = __NAMESPACE__."\\"; define(NS_BASE."BASE", dirname(__DIR__).DIRECTORY_SEPARATOR); +const DOCROOT = BASE."tests".DIRECTORY_SEPARATOR."docroot".DIRECTORY_SEPARATOR; ini_set("memory_limit", "-1"); error_reporting(\E_ALL); require_once BASE."vendor".DIRECTORY_SEPARATOR."autoload.php"; diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index c2a6b52..825bc38 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -12,6 +12,8 @@ use JKingWeb\Arsse\User; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\CLI; +use JKingWeb\Arsse\REST\Fever\User as FeverUser; +use JKingWeb\Arsse\ImportExport\OPML; use Phake; /** @covers \JKingWeb\Arsse\CLI */ @@ -61,17 +63,30 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { ["arsse.php --help", "arsse.php"], ["arsse --help", "arsse"], ["thearsse --help", "thearsse"], + ["arsse.php -h", "arsse.php"], + ["arsse -h", "arsse"], + ["thearsse -h", "thearsse"], ]; } public function testStartTheDaemon() { $srv = Phake::mock(Service::class); Phake::when($srv)->watch->thenReturn(new \DateTimeImmutable); - Phake::when($this->cli)->getService->thenReturn($srv); + Phake::when($this->cli)->getInstance(Service::class)->thenReturn($srv); $this->assertConsole($this->cli, "arsse.php daemon", 0); $this->assertLoaded(true); Phake::verify($srv)->watch(true); - Phake::verify($this->cli)->getService; + Phake::verify($this->cli)->getInstance(Service::class); + } + + public function testRefreshAllFeeds() { + $srv = Phake::mock(Service::class); + Phake::when($srv)->watch->thenReturn(new \DateTimeImmutable); + Phake::when($this->cli)->getInstance(Service::class)->thenReturn($srv); + $this->assertConsole($this->cli, "arsse.php feed refresh-all", 0); + $this->assertLoaded(true); + Phake::verify($srv)->watch(false); + Phake::verify($this->cli)->getInstance(Service::class); } /** @dataProvider provideFeedUpdates */ @@ -97,7 +112,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { 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($this->cli)->getConf->thenReturn($conf); + Phake::when($this->cli)->getInstance(Conf::class)->thenReturn($conf); $this->assertConsole($this->cli, $cmd, $exitStatus); $this->assertLoaded(false); Phake::verify($conf)->exportFile($file, true); @@ -164,16 +179,27 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { ($user === "jane.doe@example.com" && $pass === "superman") ); })); + $fever = \Phake::mock(FeverUser::class); + \Phake::when($fever)->authenticate->thenReturn(false); + \Phake::when($fever)->authenticate("john.doe@example.com", "ashalla")->thenReturn(true); + \Phake::when($fever)->authenticate("jane.doe@example.com", "thx1138")->thenReturn(true); + \Phake::when($this->cli)->getInstance(FeverUser::class)->thenReturn($fever); $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } public function provideUserAuthentication() { $l = new \JKingWeb\Arsse\Lang; + $success = $l("CLI.Auth.Success"); + $failure = $l("CLI.Auth.Failure"); return [ - ["arsse.php user auth john.doe@example.com secret", 0, $l("CLI.Auth.Success")], - ["arsse.php user auth john.doe@example.com superman", 1, $l("CLI.Auth.Failure")], - ["arsse.php user auth jane.doe@example.com secret", 1, $l("CLI.Auth.Failure")], - ["arsse.php user auth jane.doe@example.com superman", 0, $l("CLI.Auth.Success")], + ["arsse.php user auth john.doe@example.com secret", 0, $success], + ["arsse.php user auth john.doe@example.com superman", 1, $failure], + ["arsse.php user auth jane.doe@example.com secret", 1, $failure], + ["arsse.php user auth jane.doe@example.com superman", 0, $success], + ["arsse.php user auth john.doe@example.com ashalla --fever", 0, $success], + ["arsse.php user auth john.doe@example.com thx1138 --fever", 1, $failure], + ["arsse.php user auth --fever jane.doe@example.com ashalla", 1, $failure], + ["arsse.php user auth --fever jane.doe@example.com thx1138", 0, $success], ]; } @@ -199,24 +225,149 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideUserPasswordChanges */ public function testChangeAUserPassword(string $cmd, int $exitStatus, string $output) { - // 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) { + $passwordChange = function($user, $pass = null) { switch ($user) { case "jane.doe@example.com": throw new \JKingWeb\Arsse\User\Exception("doesNotExist"); case "john.doe@example.com": return is_null($pass) ? "random password" : $pass; } - })); + }; + // 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($passwordChange)); + $fever = \Phake::mock(FeverUser::class); + \Phake::when($fever)->register->thenReturnCallback($passwordChange); + \Phake::when($this->cli)->getInstance(FeverUser::class)->thenReturn($fever); $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } public function provideUserPasswordChanges() { return [ - ["arsse.php user set-pass john.doe@example.com", 0, "random password"], - ["arsse.php user set-pass john.doe@example.com superman", 0, ""], - ["arsse.php user set-pass jane.doe@example.com", 10402, ""], + ["arsse.php user set-pass john.doe@example.com", 0, "random password"], + ["arsse.php user set-pass john.doe@example.com superman", 0, ""], + ["arsse.php user set-pass jane.doe@example.com", 10402, ""], + ["arsse.php user set-pass john.doe@example.com --fever", 0, "random password"], + ["arsse.php user set-pass --fever john.doe@example.com superman", 0, ""], + ["arsse.php user set-pass jane.doe@example.com --fever", 10402, ""], + ]; + } + + /** @dataProvider provideUserPasswordClearings */ + public function testClearAUserPassword(string $cmd, int $exitStatus, string $output) { + $passwordClear = function($user) { + switch ($user) { + case "jane.doe@example.com": + throw new \JKingWeb\Arsse\User\Exception("doesNotExist"); + case "john.doe@example.com": + return true; + } + }; + // 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("passwordUnset")->will($this->returnCallback($passwordClear)); + $fever = \Phake::mock(FeverUser::class); + \Phake::when($fever)->unregister->thenReturnCallback($passwordClear); + \Phake::when($this->cli)->getInstance(FeverUser::class)->thenReturn($fever); + $this->assertConsole($this->cli, $cmd, $exitStatus, $output); + } + + public function provideUserPasswordClearings() { + return [ + ["arsse.php user unset-pass john.doe@example.com", 0, ""], + ["arsse.php user unset-pass jane.doe@example.com", 10402, ""], + ["arsse.php user unset-pass john.doe@example.com --fever", 0, ""], + ["arsse.php user unset-pass jane.doe@example.com --fever", 10402, ""], + ]; + } + + /** @dataProvider provideOpmlExports */ + public function testExportToOpml(string $cmd, int $exitStatus, string $file, string $user, bool $flat) { + $opml = Phake::mock(OPML::class); + Phake::when($opml)->exportFile("php://output", $user, $flat)->thenReturn(true); + Phake::when($opml)->exportFile("good.opml", $user, $flat)->thenReturn(true); + Phake::when($opml)->exportFile("bad.opml", $user, $flat)->thenThrow(new \JKingWeb\Arsse\ImportExport\Exception("fileUnwritable")); + Phake::when($this->cli)->getInstance(OPML::class)->thenReturn($opml); + $this->assertConsole($this->cli, $cmd, $exitStatus); + $this->assertLoaded(true); + Phake::verify($opml)->exportFile($file, $user, $flat); + } + + public function provideOpmlExports() { + return [ + ["arsse.php export john.doe@example.com", 0, "php://output", "john.doe@example.com", false], + ["arsse.php export john.doe@example.com -", 0, "php://output", "john.doe@example.com", false], + ["arsse.php export john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", false], + ["arsse.php export john.doe@example.com bad.opml", 10604, "bad.opml", "john.doe@example.com", false], + ["arsse.php export john.doe@example.com --flat", 0, "php://output", "john.doe@example.com", true], + ["arsse.php export john.doe@example.com - --flat", 0, "php://output", "john.doe@example.com", true], + ["arsse.php export --flat john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", true], + ["arsse.php export john.doe@example.com bad.opml --flat", 10604, "bad.opml", "john.doe@example.com", true], + ["arsse.php export jane.doe@example.com", 0, "php://output", "jane.doe@example.com", false], + ["arsse.php export jane.doe@example.com -", 0, "php://output", "jane.doe@example.com", false], + ["arsse.php export jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", false], + ["arsse.php export jane.doe@example.com bad.opml", 10604, "bad.opml", "jane.doe@example.com", false], + ["arsse.php export jane.doe@example.com --flat", 0, "php://output", "jane.doe@example.com", true], + ["arsse.php export jane.doe@example.com - --flat", 0, "php://output", "jane.doe@example.com", true], + ["arsse.php export --flat jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", true], + ["arsse.php export jane.doe@example.com bad.opml --flat", 10604, "bad.opml", "jane.doe@example.com", true], + ["arsse.php export john.doe@example.com -f", 0, "php://output", "john.doe@example.com", true], + ["arsse.php export john.doe@example.com - -f", 0, "php://output", "john.doe@example.com", true], + ["arsse.php export -f john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", true], + ["arsse.php export john.doe@example.com bad.opml -f", 10604, "bad.opml", "john.doe@example.com", true], + ["arsse.php export jane.doe@example.com -f", 0, "php://output", "jane.doe@example.com", true], + ["arsse.php export jane.doe@example.com - -f", 0, "php://output", "jane.doe@example.com", true], + ["arsse.php export -f jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", true], + ["arsse.php export jane.doe@example.com bad.opml -f", 10604, "bad.opml", "jane.doe@example.com", true], + ]; + } + + /** @dataProvider provideOpmlImports */ + public function testImportFromOpml(string $cmd, int $exitStatus, string $file, string $user, bool $flat, bool $replace) { + $opml = Phake::mock(OPML::class); + Phake::when($opml)->importFile("php://input", $user, $flat, $replace)->thenReturn(true); + Phake::when($opml)->importFile("good.opml", $user, $flat, $replace)->thenReturn(true); + Phake::when($opml)->importFile("bad.opml", $user, $flat, $replace)->thenThrow(new \JKingWeb\Arsse\ImportExport\Exception("fileUnreadable")); + Phake::when($this->cli)->getInstance(OPML::class)->thenReturn($opml); + $this->assertConsole($this->cli, $cmd, $exitStatus); + $this->assertLoaded(true); + Phake::verify($opml)->importFile($file, $user, $flat, $replace); + } + + public function provideOpmlImports() { + return [ + ["arsse.php import john.doe@example.com", 0, "php://input", "john.doe@example.com", false, false], + ["arsse.php import john.doe@example.com -", 0, "php://input", "john.doe@example.com", false, false], + ["arsse.php import john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", false, false], + ["arsse.php import john.doe@example.com bad.opml", 10603, "bad.opml", "john.doe@example.com", false, false], + ["arsse.php import john.doe@example.com --flat", 0, "php://input", "john.doe@example.com", true, false], + ["arsse.php import john.doe@example.com - --flat", 0, "php://input", "john.doe@example.com", true, false], + ["arsse.php import --flat john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", true, false], + ["arsse.php import john.doe@example.com bad.opml --flat", 10603, "bad.opml", "john.doe@example.com", true, false], + ["arsse.php import jane.doe@example.com", 0, "php://input", "jane.doe@example.com", false, false], + ["arsse.php import jane.doe@example.com -", 0, "php://input", "jane.doe@example.com", false, false], + ["arsse.php import jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", false, false], + ["arsse.php import jane.doe@example.com bad.opml", 10603, "bad.opml", "jane.doe@example.com", false, false], + ["arsse.php import jane.doe@example.com --flat", 0, "php://input", "jane.doe@example.com", true, false], + ["arsse.php import jane.doe@example.com - --flat", 0, "php://input", "jane.doe@example.com", true, false], + ["arsse.php import --flat jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", true, false], + ["arsse.php import jane.doe@example.com bad.opml --flat", 10603, "bad.opml", "jane.doe@example.com", true, false], + ["arsse.php import john.doe@example.com --replace", 0, "php://input", "john.doe@example.com", false, true], + ["arsse.php import john.doe@example.com - -r", 0, "php://input", "john.doe@example.com", false, true], + ["arsse.php import --replace john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", false, true], + ["arsse.php import -r john.doe@example.com bad.opml", 10603, "bad.opml", "john.doe@example.com", false, true], + ["arsse.php import --replace john.doe@example.com --flat", 0, "php://input", "john.doe@example.com", true, true], + ["arsse.php import -r john.doe@example.com - --flat", 0, "php://input", "john.doe@example.com", true, true], + ["arsse.php import --flat john.doe@example.com good.opml -r", 0, "good.opml", "john.doe@example.com", true, true], + ["arsse.php import --replace john.doe@example.com bad.opml --flat", 10603, "bad.opml", "john.doe@example.com", true, true], + ["arsse.php import jane.doe@example.com -r ", 0, "php://input", "jane.doe@example.com", false, true], + ["arsse.php import jane.doe@example.com - --replace", 0, "php://input", "jane.doe@example.com", false, true], + ["arsse.php import -r jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", false, true], + ["arsse.php import --replace jane.doe@example.com bad.opml", 10603, "bad.opml", "jane.doe@example.com", false, true], + ["arsse.php import jane.doe@example.com --flat -r", 0, "php://input", "jane.doe@example.com", true, true], + ["arsse.php import jane.doe@example.com - --flat --replace", 0, "php://input", "jane.doe@example.com", true, true], + ["arsse.php import --flat jane.doe@example.com good.opml -r", 0, "good.opml", "jane.doe@example.com", true, true], + ["arsse.php import jane.doe@example.com bad.opml --replace --flat", 10603, "bad.opml", "jane.doe@example.com", true, true], ]; } } diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index b40056e..537002f 100644 --- a/tests/cases/Database/Base.php +++ b/tests/cases/Database/Base.php @@ -8,11 +8,7 @@ namespace JKingWeb\Arsse\TestCase\Database; 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\Db\Result; -use JKingWeb\Arsse\Test\DatabaseInformation; use Phake; abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { @@ -20,11 +16,13 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { use SeriesMeta; use SeriesUser; use SeriesSession; + use SeriesToken; use SeriesFolder; use SeriesFeed; use SeriesSubscription; - use SeriesArticle; use SeriesLabel; + use SeriesTag; + use SeriesArticle; use SeriesCleanup; /** @var \JKingWeb\Arsse\Db\Driver */ @@ -66,7 +64,7 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { public function setUp() { // get the name of the test's test series - $this->series = $this->findTraitofTest($this->getName()); + $this->series = $this->findTraitofTest($this->getName(false)); static::clearData(); static::setConf(); if (strlen(static::$failureReason)) { @@ -82,13 +80,13 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { $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); + $this->primeDatabase(static::$drv, $this->data); } } public function tearDown() { // call the series-specific teardown method - $this->series = $this->findTraitofTest($this->getName()); + $this->series = $this->findTraitofTest($this->getName(false)); $tearDown = "tearDown".$this->series; $this->$tearDown(); // clean up @@ -100,128 +98,13 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { } public static function tearDownAfterClass() { - // wipe the database absolutely clean - static::dbRaze(static::$drv); - // clean up - static::$drv = null; + if (static::$drv) { + // wipe the database absolutely clean + static::dbRaze(static::$drv); + // clean up + static::$drv = null; + } static::$failureReason = ""; static::clearData(); } - - public function primeDatabase(array $data): bool { - $drv = static::$drv; - $tr = $drv->begin(); - foreach ($data as $table => $info) { - $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); - foreach ($info['rows'] as $row) { - $s->runArray($row); - } - } - $tr->commit(); - $this->primed = true; - return true; - } - - public function compareExpectations(array $expected): bool { - foreach ($expected as $table => $info) { - $cols = array_map(function($v) { - return '"'.str_replace('"', '""', $v).'"'; - }, array_keys($info['columns'])); - $cols = implode(",", $cols); - $types = $info['columns']; - $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"); - $row = array_combine($cols, $row); - foreach ($data as $index => $test) { - foreach ($test as $col => $value) { - switch ($types[$col]) { - case "datetime": - $test[$col] = $this->approximateTime($row[$col], $value); - break; - case "int": - $test[$col] = ValueInfo::normalize($value, ValueInfo::T_INT | ValueInfo::M_DROP | valueInfo::M_NULL); - break; - case "float": - $test[$col] = ValueInfo::normalize($value, ValueInfo::T_FLOAT | ValueInfo::M_DROP | valueInfo::M_NULL); - break; - case "bool": - $test[$col] = (int) ValueInfo::normalize($value, ValueInfo::T_BOOL | ValueInfo::M_DROP | valueInfo::M_NULL); - break; - } - } - if ($row===$test) { - $data[$index] = $test; - break; - } - } - $this->assertContains($row, $data, "Table $table does not contain record at array index $index."); - $found = array_search($row, $data, true); - unset($data[$found]); - } - $this->assertSame([], $data); - } - return true; - } - - public function primeExpectations(array $source, array $tableSpecs = null): array { - $out = []; - foreach ($tableSpecs as $table => $columns) { - // make sure the source has the table we want - $this->assertArrayHasKey($table, $source, "Source for expectations does not contain requested table $table."); - $out[$table] = [ - 'columns' => [], - 'rows' => array_fill(0, sizeof($source[$table]['rows']), []), - ]; - // make sure the source has all the columns we want for the table - $cols = array_flip($columns); - $cols = array_intersect_key($cols, $source[$table]['columns']); - $this->assertSame(array_keys($cols), $columns, "Source for table $table does not contain all requested columns"); - // get a map of source value offsets and keys - $targets = array_flip(array_keys($source[$table]['columns'])); - foreach ($cols as $key => $order) { - // fill the column-spec - $out[$table]['columns'][$key] = $source[$table]['columns'][$key]; - foreach ($source[$table]['rows'] as $index => $row) { - // fill each row column-wise with re-ordered values - $out[$table]['rows'][$index][$order] = $row[$targets[$key]]; - } - } - } - return $out; - } - - public function assertResult(array $expected, Result $data) { - $data = $data->getAll(); - $this->assertCount(sizeof($expected), $data, "Number of result rows (".sizeof($data).") differs from number of expected rows (".sizeof($expected).")"); - if (sizeof($expected)) { - // make sure the expectations are consistent - foreach ($expected as $exp) { - if (!isset($keys)) { - $keys = $exp; - continue; - } - $this->assertSame(array_keys($keys), array_keys($exp), "Result set expectations are irregular"); - } - // filter the result set to contain just the desired keys (we don't care if the result has extra keys) - $rows = []; - foreach ($data as $row) { - $rows[] = array_intersect_key($row, $keys); - } - // compare the result set to the expectations - foreach ($rows as $row) { - $this->assertContains($row, $expected, "Result set contains unexpected record."); - $found = array_search($row, $expected); - unset($expected[$found]); - } - $this->assertArraySubset($expected, [], false, "Expectations not in result set."); - } - } } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index c3c4425..75fba45 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -8,8 +8,9 @@ namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\Date; +use JKingWeb\Arsse\Misc\ValueInfo; use Phake; trait SeriesArticle { @@ -19,15 +20,36 @@ trait SeriesArticle { '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"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], + ["john.doe@example.org", ""], + ["john.doe@example.net", ""], ], ], + '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"], + ] + ], 'arsse_folders' => [ 'columns' => [ 'id' => "int", @@ -47,26 +69,21 @@ trait SeriesArticle { [9, "john.doe@example.net", null, "Politics"], ] ], - 'arsse_feeds' => [ + 'arsse_tags' => [ 'columns' => [ - 'id' => "int", - 'url' => "str", - 'title' => "str", + 'id' => "int", + 'owner' => "str", + 'name' => "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"], + [1, "john.doe@example.com", "Technology"], + [2, "john.doe@example.com", "Software"], + [3, "john.doe@example.com", "Rocketry"], + [4, "jane.doe@example.com", "Politics"], + [5, "john.doe@example.com", "Politics"], + [6, "john.doe@example.net", "Technology"], + [7, "john.doe@example.net", "Software"], + [8, "john.doe@example.net", "Politics"], ] ], 'arsse_subscriptions' => [ @@ -94,6 +111,25 @@ trait SeriesArticle { [14,"john.doe@example.net",4, 7,null], ] ], + 'arsse_tag_members' => [ + 'columns' => [ + 'tag' => "int", + 'subscription' => "int", + 'assigned' => "bool", + ], + 'rows' => [ + [1,3,1], + [1,4,1], + [2,4,1], + [5,1,0], + [5,4,1], + [5,5,1], + [6,13,1], + [6,14,1], + [7,13,1], + [8,12,1], + ], + ], 'arsse_articles' => [ 'columns' => [ 'id' => "int", @@ -111,13 +147,13 @@ trait SeriesArticle { '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"], + [1,1,null,"Title one", null,null,null,"First article", null,"","","","2000-01-01T00:00:00Z"], + [2,1,null,"Title two", null,null,null,"Second article",null,"","","","2010-01-01T00:00:00Z"], + [3,2,null,"Title three",null,null,null,"Third article", null,"","","","2000-01-01T00:00:00Z"], + [4,2,null,null,"John Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [5,3,null,null,"John Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [6,3,null,null,"Jane Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z"], + [7,4,null,null,"Jane Doe",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"], @@ -377,6 +413,110 @@ trait SeriesArticle { unset($this->data, $this->matches, $this->fields, $this->checkTables, $this->user); } + /** @dataProvider provideContextMatches */ + public function testListArticlesCheckingContext(Context $c, array $exp) { + $ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c)->getAll(), "id"); + sort($ids); + sort($exp); + $this->assertEquals($exp, $ids); + } + + public function provideContextMatches() { + return [ + 'Blank context' => [new Context, [1,2,3,4,5,6,7,8,19,20]], + 'Folder tree' => [(new Context)->folder(1), [5,6,7,8]], + 'Entire folder tree' => [(new Context)->folder(0), [1,2,3,4,5,6,7,8,19,20]], + 'Leaf folder' => [(new Context)->folder(6), [7,8]], + 'Multiple folder trees' => [(new Context)->folders([1,5]), [5,6,7,8,19,20]], + 'Multiple folder trees including root' => [(new Context)->folders([0,1,5]), [1,2,3,4,5,6,7,8,19,20]], + 'Shallow folder' => [(new Context)->folderShallow(1), [5,6]], + 'Root folder only' => [(new Context)->folderShallow(0), [1,2,3,4]], + 'Multiple shallow folders' => [(new Context)->foldersShallow([1,6]), [5,6,7,8]], + 'Subscription' => [(new Context)->subscription(5), [19,20]], + 'Multiple subscriptions' => [(new Context)->subscriptions([4,5]), [7,8,19,20]], + 'Unread' => [(new Context)->subscription(5)->unread(true), [20]], + 'Read' => [(new Context)->subscription(5)->unread(false), [19]], + 'Starred' => [(new Context)->starred(true), [1,20]], + 'Unstarred' => [(new Context)->starred(false), [2,3,4,5,6,7,8,19]], + 'Starred and Read' => [(new Context)->starred(true)->unread(false), [1]], + 'Starred and Read in subscription' => [(new Context)->starred(true)->unread(false)->subscription(5), []], + 'Annotated' => [(new Context)->annotated(true), [2]], + 'Not annotated' => [(new Context)->annotated(false), [1,3,4,5,6,7,8,19,20]], + 'Labelled' => [(new Context)->labelled(true), [1,5,8,19,20]], + 'Not labelled' => [(new Context)->labelled(false), [2,3,4,6,7]], + 'Not after edition 999' => [(new Context)->subscription(5)->latestEdition(999), [19]], + 'Not after edition 19' => [(new Context)->subscription(5)->latestEdition(19), [19]], + 'Not before edition 999' => [(new Context)->subscription(5)->oldestEdition(999), [20]], + 'Not before edition 1001' => [(new Context)->subscription(5)->oldestEdition(1001), [20]], + 'Not after article 3' => [(new Context)->latestArticle(3), [1,2,3]], + 'Not before article 19' => [(new Context)->oldestArticle(19), [19,20]], + 'Modified by author since 2005' => [(new Context)->modifiedSince("2005-01-01T00:00:00Z"), [2,4,6,8,20]], + 'Modified by author since 2010' => [(new Context)->modifiedSince("2010-01-01T00:00:00Z"), [2,4,6,8,20]], + 'Not modified by author since 2005' => [(new Context)->notModifiedSince("2005-01-01T00:00:00Z"), [1,3,5,7,19]], + 'Not modified by author since 2000' => [(new Context)->notModifiedSince("2000-01-01T00:00:00Z"), [1,3,5,7,19]], + 'Marked or labelled since 2014' => [(new Context)->markedSince("2014-01-01T00:00:00Z"), [8,19]], + 'Marked or labelled since 2010' => [(new Context)->markedSince("2010-01-01T00:00:00Z"), [2,4,6,8,19,20]], + 'Not marked or labelled since 2014' => [(new Context)->notMarkedSince("2014-01-01T00:00:00Z"), [1,2,3,4,5,6,7,20]], + 'Not marked or labelled since 2005' => [(new Context)->notMarkedSince("2005-01-01T00:00:00Z"), [1,3,5,7]], + 'Marked or labelled between 2000 and 2015' => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]], + 'Marked or labelled in 2010' => [(new Context)->markedSince("2010-01-01T00:00:00Z")->notMarkedSince("2010-12-31T23:59:59Z"), [2,4,6,20]], + 'Paged results' => [(new Context)->limit(2)->oldestEdition(4), [4,5]], + 'With label ID 1' => [(new Context)->label(1), [1,19]], + 'With label ID 2' => [(new Context)->label(2), [1,5,20]], + 'With label ID 1 or 2' => [(new Context)->labels([1,2]), [1,5,19,20]], + 'With label "Interesting"' => [(new Context)->labelName("Interesting"), [1,19]], + 'With label "Fascinating"' => [(new Context)->labelName("Fascinating"), [1,5,20]], + 'With label "Interesting" or "Fascinating"' => [(new Context)->labelNames(["Interesting","Fascinating"]), [1,5,19,20]], + 'Article ID 20' => [(new Context)->article(20), [20]], + 'Edition ID 1001' => [(new Context)->edition(1001), [20]], + 'Multiple articles' => [(new Context)->articles([1,20,50]), [1,20]], + 'Multiple starred articles' => [(new Context)->articles([1,2,3])->starred(true), [1]], + 'Multiple unstarred articles' => [(new Context)->articles([1,2,3])->starred(false), [2,3]], + 'Multiple articles' => [(new Context)->articles([1,20,50]), [1,20]], + 'Multiple editions' => [(new Context)->editions([1,1001,50]), [1,20]], + '150 articles' => [(new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)), [1,2,3,4,5,6,7,8,19,20]], + 'Search title or content 1' => [(new Context)->searchTerms(["Article"]), [1,2,3]], + 'Search title or content 2' => [(new Context)->searchTerms(["one", "first"]), [1]], + 'Search title or content 3' => [(new Context)->searchTerms(["one first"]), []], + 'Search title 1' => [(new Context)->titleTerms(["two"]), [2]], + 'Search title 2' => [(new Context)->titleTerms(["title two"]), [2]], + 'Search title 3' => [(new Context)->titleTerms(["two", "title"]), [2]], + 'Search title 4' => [(new Context)->titleTerms(["two title"]), []], + 'Search note 1' => [(new Context)->annotationTerms(["some"]), [2]], + 'Search note 2' => [(new Context)->annotationTerms(["some Note"]), [2]], + 'Search note 3' => [(new Context)->annotationTerms(["note", "some"]), [2]], + 'Search note 4' => [(new Context)->annotationTerms(["some", "sauce"]), []], + 'Search author 1' => [(new Context)->authorTerms(["doe"]), [4,5,6,7]], + 'Search author 2' => [(new Context)->authorTerms(["jane doe"]), [6,7]], + 'Search author 3' => [(new Context)->authorTerms(["doe", "jane"]), [6,7]], + 'Search author 4' => [(new Context)->authorTerms(["doe jane"]), []], + 'Folder tree 1 excluding subscription 4' => [(new Context)->not->subscription(4)->folder(1), [5,6]], + 'Folder tree 1 excluding articles 7 and 8' => [(new Context)->folder(1)->not->articles([7,8]), [5,6]], + 'Folder tree 1 excluding no articles' => [(new Context)->folder(1)->not->articles([]), [5,6,7,8]], + 'Marked or labelled between 2000 and 2015 excluding in 2010' => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59")->not->markedSince("2010-01-01T00:00:00Z")->not->notMarkedSince("2010-12-31T23:59:59Z"), [1,3,5,7,8]], + 'Search with exclusion' => [(new Context)->searchTerms(["Article"])->not->searchTerms(["one", "two"]), [3]], + 'Excluded folder tree' => [(new Context)->not->folder(1), [1,2,3,4,19,20]], + 'Excluding label ID 2' => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], + 'Excluding label "Fascinating"' => [(new Context)->not->labelName("Fascinating"), [2,3,4,6,7,8,19]], + 'Search 501 terms' => [(new Context)->searchTerms(array_merge(range(1, 500), [str_repeat("a", 1000)])), []], + 'With tag ID 1' => [(new Context)->tag(1), [5,6,7,8]], + 'With tag ID 5' => [(new Context)->tag(5), [7,8,19,20]], + 'With tag ID 1 or 5' => [(new Context)->tags([1,5]), [5,6,7,8,19,20]], + 'With tag "Technology"' => [(new Context)->tagName("Technology"), [5,6,7,8]], + 'With tag "Politics"' => [(new Context)->tagName("Politics"), [7,8,19,20]], + 'With tag "Technology" or "Politics"' => [(new Context)->tagNames(["Technology","Politics"]), [5,6,7,8,19,20]], + 'Excluding tag ID 1' => [(new Context)->not->tag(1), [1,2,3,4,19,20]], + 'Excluding tag ID 5' => [(new Context)->not->tag(5), [1,2,3,4,5,6]], + 'Excluding tag "Technology"' => [(new Context)->not->tagName("Technology"), [1,2,3,4,19,20]], + 'Excluding tag "Politics"' => [(new Context)->not->tagName("Politics"), [1,2,3,4,5,6]], + 'Excluding tags ID 1 and 5' => [(new Context)->not->tags([1,5]), [1,2,3,4]], + 'Excluding tags "Technology" and "Politics"' => [(new Context)->not->tagNames(["Technology","Politics"]), [1,2,3,4]], + 'Excluding entire folder tree' => [(new Context)->not->folder(0), []], + 'Excluding multiple folder trees' => [(new Context)->not->folders([1,5]), [1,2,3,4]], + 'Excluding multiple folder trees including root' => [(new Context)->not->folders([0,1,5]), []], + ]; + } + public function testRetrieveArticleIdsForEditions() { $exp = [ 1 => 1, @@ -414,88 +554,6 @@ trait SeriesArticle { $this->assertEquals($exp, Arsse::$db->editionArticle(...range(1, 1001))); } - public function testListArticlesCheckingContext() { - $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]; - $compareIds($exp, new Context); - $compareIds($exp, (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3))); - // get items from a folder tree - $compareIds([5,6,7,8], (new Context)->folder(1)); - // get items from a leaf folder - $compareIds([7,8], (new Context)->folder(6)); - // get items from a non-leaf folder without descending - $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]; - $compareIds($exp, (new Context)->subscription(5)); - // get un/read items from a single subscription - $compareIds([20], (new Context)->subscription(5)->unread(true)); - $compareIds([19], (new Context)->subscription(5)->unread(false)); - // get starred articles - $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 - $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 - $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]; - $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]; - $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) - $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 - $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 - $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 - $compareIds([1,19], (new Context)->label(1)); - $compareIds([1,5,20], (new Context)->label(2)); - // get articles by label name - $compareIds([1,19], (new Context)->labelName("Interesting")); - $compareIds([1,5,20], (new Context)->labelName("Fascinating")); - // get articles with any or no label - $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 - $compareIds([20], (new Context)->article(20)); - $compareIds([20], (new Context)->edition(1001)); - // get multiple specific articles or editions - $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 - $compareIds([1,3,4,5,6,7,8,19,20], (new Context)->annotated(false)); - $compareIds([2], (new Context)->annotated(true)); - // get specific starred articles - $compareIds([1], (new Context)->articles([1,2,3])->starred(true)); - $compareIds([2,3], (new Context)->articles([1,2,3])->starred(false)); - } - public function testListArticlesOfAMissingFolder() { $this->assertException("idMissing", "Db", "ExceptionInput"); Arsse::$db->articleList($this->user, (new Context)->folder(1)); @@ -519,6 +577,25 @@ trait SeriesArticle { $this->assertEquals($this->fields, $test); } + /** @dataProvider provideOrderedLists */ + public function testListArticlesCheckingOrder(array $sortCols, array $exp) { + $act = ValueInfo::normalize(array_column(iterator_to_array(Arsse::$db->articleList("john.doe@example.com", null, ["id"], $sortCols)), "id"), ValueInfo::T_INT | ValueInfo::M_ARRAY); + $this->assertSame($exp, $act); + } + + public function provideOrderedLists() { + return [ + [["id"], [1,2,3,4,5,6,7,8,19,20]], + [["id asc"], [1,2,3,4,5,6,7,8,19,20]], + [["id desc"], [20,19,8,7,6,5,4,3,2,1]], + [["edition"], [1,2,3,4,5,6,7,8,19,20]], + [["edition asc"], [1,2,3,4,5,6,7,8,19,20]], + [["edition desc"], [20,19,8,7,6,5,4,3,2,1]], + [["id", "edition desk"], [1,2,3,4,5,6,7,8,19,20]], + [["id", "editio"], [1,2,3,4,5,6,7,8,19,20]], + ]; + } + public function testListArticlesWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); @@ -537,7 +614,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][9][4] = $now; $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesRead() { @@ -552,7 +629,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'']; $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'']; $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesUnstarred() { @@ -563,7 +640,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][10][4] = $now; $state['arsse_marks']['rows'][11][3] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesStarred() { @@ -578,7 +655,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'']; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesUnreadAndUnstarred() { @@ -592,7 +669,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][3] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesReadAndStarred() { @@ -610,7 +687,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][] = [13,6,1,1,$now,'']; $state['arsse_marks']['rows'][] = [14,7,1,1,$now,'']; $state['arsse_marks']['rows'][] = [14,8,1,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesUnreadAndStarred() { @@ -628,7 +705,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'']; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesReadAndUnstarred() { @@ -646,7 +723,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'']; $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'']; $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testSetNoteForAllArticles() { @@ -665,7 +742,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][] = [13,6,0,0,$now,'New note']; $state['arsse_marks']['rows'][] = [14,7,0,0,$now,'New note']; $state['arsse_marks']['rows'][] = [14,8,0,0,$now,'New note']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkATreeFolder() { @@ -676,7 +753,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'']; $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'']; $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkALeafFolder() { @@ -685,7 +762,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'']; $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAMissingFolder() { @@ -699,7 +776,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'']; $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAMissingSubscription() { @@ -713,7 +790,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleArticles() { @@ -723,7 +800,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleArticlessUnreadAndStarred() { @@ -736,16 +813,11 @@ trait SeriesArticle { $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; - $this->compareExpectations($state); - } - - public function testMarkTooFewMultipleArticles() { - $this->assertException("tooShort", "Db", "ExceptionInput"); - Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles([])); + $this->compareExpectations(static::$drv, $state); } public function testMarkTooManyMultipleArticles() { - $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)))); + $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)))); } public function testMarkAMissingArticle() { @@ -759,7 +831,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleEditions() { @@ -769,13 +841,13 @@ trait SeriesArticle { $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $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); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleEditionsUnread() { @@ -786,7 +858,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][9][4] = $now; $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleEditionsUnreadWithStale() { @@ -795,7 +867,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleEditionsUnreadAndStarredWithStale() { @@ -807,12 +879,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; - $this->compareExpectations($state); - } - - public function testMarkTooFewMultipleEditions() { - $this->assertException("tooShort", "Db", "ExceptionInput"); - Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions([])); + $this->compareExpectations(static::$drv, $state); } public function testMarkTooManyMultipleEditions() { @@ -822,7 +889,7 @@ trait SeriesArticle { public function testMarkAStaleEditionUnread() { Arsse::$db->articleMark($this->user, ['read'=>false], (new Context)->edition(20)); // no changes occur $state = $this->primeExpectations($this->data, $this->checkTables); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAStaleEditionStarred() { @@ -831,7 +898,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAStaleEditionUnreadAndStarred() { @@ -840,13 +907,13 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAStaleEditionUnreadAndUnstarred() { Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>false], (new Context)->edition(20)); // no changes occur $state = $this->primeExpectations($this->data, $this->checkTables); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAMissingEdition() { @@ -862,7 +929,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][8][4] = $now; $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkByLatestEdition() { @@ -875,7 +942,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'']; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkByLastMarked() { @@ -886,7 +953,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][8][4] = $now; $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkByNotLastMarked() { @@ -895,7 +962,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][] = [13,5,0,1,$now,'']; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkArticlesWithoutAuthority() { @@ -908,7 +975,7 @@ trait SeriesArticle { $this->assertSame(2, Arsse::$db->articleCount("john.doe@example.com", (new Context)->starred(true))); $this->assertSame(4, Arsse::$db->articleCount("john.doe@example.com", (new Context)->folder(1))); $this->assertSame(0, Arsse::$db->articleCount("jane.doe@example.com", (new Context)->starred(true))); - $this->assertSame(10, Arsse::$db->articleCount("john.doe@example.com", (new Context)->articles(range(1, Database::LIMIT_ARTICLES *3)))); + $this->assertSame(10, Arsse::$db->articleCount("john.doe@example.com", (new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)))); } public function testCountArticlesWithoutAuthority() { @@ -985,4 +1052,21 @@ trait SeriesArticle { $this->assertException("notAuthorized", "User", "ExceptionAuthz"); Arsse::$db->articleCategoriesGet($this->user, 19); } + + /** @dataProvider provideArrayContextOptions */ + public function testUseTooFewValuesInArrayContext(string $option) { + $this->assertException("tooShort", "Db", "ExceptionInput"); + Arsse::$db->articleList($this->user, (new Context)->$option([])); + } + + public function provideArrayContextOptions() { + foreach ([ + "articles", "editions", + "subscriptions", "foldersShallow", //"folders", + "tags", "tagNames", "labels", "labelNames", + "searchTerms", "authorTerms", "annotationTerms", + ] as $method) { + yield [$method]; + } + } } diff --git a/tests/cases/Database/SeriesCleanup.php b/tests/cases/Database/SeriesCleanup.php index f8b4199..9d2e009 100644 --- a/tests/cases/Database/SeriesCleanup.php +++ b/tests/cases/Database/SeriesCleanup.php @@ -29,11 +29,10 @@ trait SeriesCleanup { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], 'arsse_sessions' => [ @@ -51,6 +50,20 @@ trait SeriesCleanup { ["e", $daysago, $nowish, "jane.doe@example.com"], // created more than a day ago and expired, thus deleted ], ], + 'arsse_tokens' => [ + 'columns' => [ + 'id' => "str", + 'class' => "str", + 'user' => "str", + 'expires' => "datetime", + ], + 'rows' => [ + ["80fa94c1a11f11e78667001e673b2560", "fever.login", "jane.doe@example.com", $faroff], + ["27c6de8da13311e78667001e673b2560", "fever.login", "jane.doe@example.com", $weeksago], // expired + ["ab3b3eb8a13311e78667001e673b2560", "class.class", "jane.doe@example.com", null], + ["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $soon], + ], + ], 'arsse_feeds' => [ 'columns' => [ 'id' => "int", @@ -148,7 +161,7 @@ trait SeriesCleanup { $state['arsse_feeds']['rows'][0][1] = null; unset($state['arsse_feeds']['rows'][1]); $state['arsse_feeds']['rows'][2][1] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpOrphanedFeedsWithUnlimitedRetention() { @@ -162,7 +175,7 @@ trait SeriesCleanup { ]); $state['arsse_feeds']['rows'][0][1] = null; $state['arsse_feeds']['rows'][2][1] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpOldArticlesWithStandardRetention() { @@ -173,7 +186,7 @@ trait SeriesCleanup { foreach ([7,8,9] as $id) { unset($state['arsse_articles']['rows'][$id - 1]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpOldArticlesWithUnlimitedReadRetention() { @@ -187,7 +200,7 @@ trait SeriesCleanup { foreach ([7,8] as $id) { unset($state['arsse_articles']['rows'][$id - 1]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpOldArticlesWithUnlimitedUnreadRetention() { @@ -201,7 +214,7 @@ trait SeriesCleanup { foreach ([9] as $id) { unset($state['arsse_articles']['rows'][$id - 1]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpOldArticlesWithUnlimitedRetention() { @@ -213,7 +226,7 @@ trait SeriesCleanup { $state = $this->primeExpectations($this->data, [ 'arsse_articles' => ["id"] ]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpExpiredSessions() { @@ -224,6 +237,17 @@ trait SeriesCleanup { foreach ([3,4,5] as $id) { unset($state['arsse_sessions']['rows'][$id - 1]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); + } + + public function testCleanUpExpiredTokens() { + Arsse::$db->tokenCleanup(); + $state = $this->primeExpectations($this->data, [ + 'arsse_tokens' => ["id", "class"] + ]); + foreach ([2] as $id) { + unset($state['arsse_tokens']['rows'][$id - 1]); + } + $this->compareExpectations(static::$drv, $state); } } diff --git a/tests/cases/Database/SeriesFeed.php b/tests/cases/Database/SeriesFeed.php index c7cd2a4..8576bdf 100644 --- a/tests/cases/Database/SeriesFeed.php +++ b/tests/cases/Database/SeriesFeed.php @@ -22,11 +22,10 @@ trait SeriesFeed { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], 'arsse_feeds' => [ @@ -205,7 +204,7 @@ trait SeriesFeed { $state['arsse_marks']['rows'][3] = [6,4,0,0,$now]; $state['arsse_marks']['rows'][6] = [1,3,0,0,$now]; $state['arsse_feeds']['rows'][0] = [1,6]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // update a valid feed which previously had an error Arsse::$db->feedUpdate(2); // update an erroneous feed which previously had no errors @@ -215,12 +214,12 @@ trait SeriesFeed { ]); $state['arsse_feeds']['rows'][1] = [2,0,""]; $state['arsse_feeds']['rows'][2] = [3,1,'Feed URL "http://localhost:8000/Feed/Fetching/Error?code=404" is invalid']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // update the bad feed again, twice Arsse::$db->feedUpdate(3); Arsse::$db->feedUpdate(3); $state['arsse_feeds']['rows'][2] = [3,3,'Feed URL "http://localhost:8000/Feed/Fetching/Error?code=404" is invalid']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testUpdateAMissingFeed() { @@ -255,7 +254,7 @@ trait SeriesFeed { ["Bodybuilders"], ["Men"], ]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testListStaleFeeds() { diff --git a/tests/cases/Database/SeriesFolder.php b/tests/cases/Database/SeriesFolder.php index 7265f07..367c024 100644 --- a/tests/cases/Database/SeriesFolder.php +++ b/tests/cases/Database/SeriesFolder.php @@ -16,11 +16,10 @@ trait SeriesFolder { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], 'arsse_folders' => [ @@ -49,6 +48,49 @@ trait SeriesFolder { [6, "john.doe@example.com", 2, "Politics"], ] ], + '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"], + ] + ], + '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",5, 5], + [6, "john.doe@example.com",10, 5], + [7, "jane.doe@example.com",1, null], + [8, "jane.doe@example.com",10,null], + [9, "jane.doe@example.com",2, 4], + [10,"jane.doe@example.com",3, 4], + [11,"jane.doe@example.com",4, 4], + ] + ], ]; } @@ -63,7 +105,7 @@ trait SeriesFolder { Phake::verify(Arsse::$user)->authorize($user, "folderAdd"); $state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]); $state['arsse_folders']['rows'][] = [$folderID, $user, null, "Entertainment"]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddADuplicateRootFolder() { @@ -78,7 +120,7 @@ trait SeriesFolder { Phake::verify(Arsse::$user)->authorize($user, "folderAdd"); $state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]); $state['arsse_folders']['rows'][] = [$folderID, $user, 2, "GNOME"]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddANestedFolderToAMissingParent() { @@ -119,8 +161,8 @@ trait SeriesFolder { public function testListRootFolders() { $exp = [ - ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0], - ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2], + ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0, 'feeds' => 2], + ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2, 'feeds' => 1], ]; $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", null, false)); $exp = [ @@ -136,17 +178,17 @@ trait SeriesFolder { public function testListFoldersRecursively() { $exp = [ - ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0], - ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0], - ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0], - ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1], - ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2], + ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0, 'feeds' => 2], + ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0, 'feeds' => 1], + ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0, 'feeds' => 0], + ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1, 'feeds' => 0], + ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2, 'feeds' => 1], ]; $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", null, true)); $exp = [ - ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0], - ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0], - ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1], + ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0, 'feeds' => 1], + ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0, 'feeds' => 0], + ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1, 'feeds' => 0], ]; $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", 1, true)); $exp = []; @@ -176,7 +218,7 @@ trait SeriesFolder { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderRemove"); $state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]); array_pop($state['arsse_folders']['rows']); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAFolderTree() { @@ -186,7 +228,7 @@ trait SeriesFolder { foreach ([0,1,2,5] as $index) { unset($state['arsse_folders']['rows'][$index]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAMissingFolder() { @@ -250,7 +292,7 @@ trait SeriesFolder { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderPropertiesSet"); $state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]); $state['arsse_folders']['rows'][5][3] = "Opinion"; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRenameTheRootFolder() { @@ -277,7 +319,7 @@ trait SeriesFolder { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderPropertiesSet"); $state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]); $state['arsse_folders']['rows'][5][2] = 5; // parent should have changed - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMoveTheRootFolder() { diff --git a/tests/cases/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php index 8347ce5..ec767e6 100644 --- a/tests/cases/Database/SeriesLabel.php +++ b/tests/cases/Database/SeriesLabel.php @@ -7,7 +7,8 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Database; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\Date; use Phake; @@ -18,13 +19,12 @@ trait SeriesLabel { '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"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], + ["john.doe@example.org", ""], + ["john.doe@example.net", ""], ], ], 'arsse_folders' => [ @@ -257,7 +257,7 @@ trait SeriesLabel { Phake::verify(Arsse::$user)->authorize($user, "labelAdd"); $state = $this->primeExpectations($this->data, $this->checkLabels); $state['arsse_labels']['rows'][] = [$labelID, $user, "Entertaining"]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddADuplicateLabel() { @@ -313,7 +313,7 @@ trait SeriesLabel { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelRemove"); $state = $this->primeExpectations($this->data, $this->checkLabels); array_shift($state['arsse_labels']['rows']); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveALabelByName() { @@ -321,7 +321,7 @@ trait SeriesLabel { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelRemove"); $state = $this->primeExpectations($this->data, $this->checkLabels); array_shift($state['arsse_labels']['rows']); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAMissingLabel() { @@ -397,7 +397,7 @@ trait SeriesLabel { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelPropertiesSet"); $state = $this->primeExpectations($this->data, $this->checkLabels); $state['arsse_labels']['rows'][0][2] = "Curious"; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRenameALabelByName() { @@ -405,7 +405,7 @@ trait SeriesLabel { Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelPropertiesSet"); $state = $this->primeExpectations($this->data, $this->checkLabels); $state['arsse_labels']['rows'][0][2] = "Curious"; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRenameALabelToTheEmptyString() { @@ -487,29 +487,59 @@ trait SeriesLabel { $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][4][3] = 1; $state['arsse_label_members']['rows'][] = [1,2,1,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testClearALabelFromArticles() { - Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([1,5]), true); + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([1,5]), Database::ASSOC_REMOVE); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][0][3] = 0; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testApplyALabelToArticlesByName() { - Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([2,5]), false, true); + Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([2,5]), Database::ASSOC_ADD, true); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][4][3] = 1; $state['arsse_label_members']['rows'][] = [1,2,1,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testClearALabelFromArticlesByName() { - Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([1,5]), true, true); + Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([1,5]), Database::ASSOC_REMOVE, true); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][0][3] = 0; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); + } + + public function testApplyALabelToNoArticles() { + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([10000])); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $this->compareExpectations(static::$drv, $state); + } + + public function testClearALabelFromNoArticles() { + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([10000]), Database::ASSOC_REMOVE); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $this->compareExpectations(static::$drv, $state); + } + + public function testReplaceArticlesOfALabel() { + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([2,5]), Database::ASSOC_REPLACE); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_label_members']['rows'][0][3] = 0; + $state['arsse_label_members']['rows'][2][3] = 0; + $state['arsse_label_members']['rows'][4][3] = 1; + $state['arsse_label_members']['rows'][] = [1,2,1,1]; + $this->compareExpectations(static::$drv, $state); + } + + public function testPurgeArticlesOfALabel() { + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([10000]), Database::ASSOC_REPLACE); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_label_members']['rows'][0][3] = 0; + $state['arsse_label_members']['rows'][2][3] = 0; + $this->compareExpectations(static::$drv, $state); } public function testApplyALabelToArticlesWithoutAuthority() { diff --git a/tests/cases/Database/SeriesMeta.php b/tests/cases/Database/SeriesMeta.php index 538700a..485c715 100644 --- a/tests/cases/Database/SeriesMeta.php +++ b/tests/cases/Database/SeriesMeta.php @@ -28,7 +28,7 @@ trait SeriesMeta { // 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($dataBare); + $this->primeDatabase(static::$drv, $dataBare); } protected function tearDownSeriesMeta() { @@ -39,7 +39,7 @@ trait SeriesMeta { $this->assertTrue(Arsse::$db->metaSet("favourite", "Cygnus X-1")); $state = $this->primeExpectations($this->data, ['arsse_meta' => ['key','value']]); $state['arsse_meta']['rows'][] = ["favourite","Cygnus X-1"]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddANewTypedValue() { @@ -52,14 +52,14 @@ trait SeriesMeta { $state['arsse_meta']['rows'][] = ["true","1"]; $state['arsse_meta']['rows'][] = ["false","0"]; $state['arsse_meta']['rows'][] = ["millennium","2000-01-01 00:00:00"]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testChangeAnExistingValue() { $this->assertTrue(Arsse::$db->metaSet("album", "Hemispheres")); $state = $this->primeExpectations($this->data, ['arsse_meta' => ['key','value']]); $state['arsse_meta']['rows'][1][1] = "Hemispheres"; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAValue() { @@ -67,7 +67,7 @@ trait SeriesMeta { $this->assertFalse(Arsse::$db->metaRemove("album")); $state = $this->primeExpectations($this->data, ['arsse_meta' => ['key','value']]); unset($state['arsse_meta']['rows'][1]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRetrieveAValue() { diff --git a/tests/cases/Database/SeriesMiscellany.php b/tests/cases/Database/SeriesMiscellany.php index 0080356..a7591bb 100644 --- a/tests/cases/Database/SeriesMiscellany.php +++ b/tests/cases/Database/SeriesMiscellany.php @@ -44,4 +44,8 @@ trait SeriesMiscellany { public function testCheckCharacterSetAcceptability() { $this->assertInternalType("bool", Arsse::$db->driverCharsetAcceptable()); } + + public function testPerformMaintenance() { + $this->assertTrue(Arsse::$db->driverMaintenance()); + } } diff --git a/tests/cases/Database/SeriesSession.php b/tests/cases/Database/SeriesSession.php index c986742..9e8a388 100644 --- a/tests/cases/Database/SeriesSession.php +++ b/tests/cases/Database/SeriesSession.php @@ -27,11 +27,10 @@ trait SeriesSession { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], 'arsse_sessions' => [ @@ -70,7 +69,7 @@ trait SeriesSession { // sessions near timeout should be refreshed automatically $state = $this->primeExpectations($this->data, ['arsse_sessions' => ["id", "created", "expires", "user"]]); $state['arsse_sessions']['rows'][3][2] = Date::transform(Date::add(Arsse::$conf->userSessionTimeout, $now), "sql"); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // session resumption should not check authorization Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertArraySubset($exp1, Arsse::$db->sessionResume("80fa94c1a11f11e78667001e673b2560")); @@ -97,7 +96,7 @@ trait SeriesSession { $now = time(); $state = $this->primeExpectations($this->data, ['arsse_sessions' => ["id", "created", "expires", "user"]]); $state['arsse_sessions']['rows'][] = [$id, Date::transform($now, "sql"), Date::transform(Date::add(Arsse::$conf->userSessionTimeout, $now), "sql"), $user]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCreateASessionWithoutAuthority() { @@ -112,11 +111,21 @@ trait SeriesSession { $this->assertTrue(Arsse::$db->sessionDestroy($user, $id)); $state = $this->primeExpectations($this->data, ['arsse_sessions' => ["id", "created", "expires", "user"]]); unset($state['arsse_sessions']['rows'][0]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // destroying a session which does not exist is not an error $this->assertFalse(Arsse::$db->sessionDestroy($user, $id)); } + public function testDestroyAllSessions() { + $user = "jane.doe@example.com"; + $this->assertTrue(Arsse::$db->sessionDestroy($user)); + $state = $this->primeExpectations($this->data, ['arsse_sessions' => ["id", "created", "expires", "user"]]); + unset($state['arsse_sessions']['rows'][0]); + unset($state['arsse_sessions']['rows'][1]); + unset($state['arsse_sessions']['rows'][2]); + $this->compareExpectations(static::$drv, $state); + } + public function testDestroyASessionForTheWrongUser() { $user = "john.doe@example.com"; $id = "80fa94c1a11f11e78667001e673b2560"; diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php index f2811f1..be06be8 100644 --- a/tests/cases/Database/SeriesSubscription.php +++ b/tests/cases/Database/SeriesSubscription.php @@ -18,11 +18,10 @@ trait SeriesSubscription { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], 'arsse_folders' => [ @@ -48,6 +47,7 @@ trait SeriesSubscription { 'title' => "str", 'username' => "str", 'password' => "str", + 'updated' => "datetime", 'next_fetch' => "datetime", 'favicon' => "str", ], @@ -69,6 +69,33 @@ trait SeriesSubscription { [3,"john.doe@example.com",3,"Ook",2,0,1], ] ], + 'arsse_tags' => [ + '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"], + ], + ], + 'arsse_tag_members' => [ + 'columns' => [ + 'tag' => "int", + 'subscription' => "int", + 'assigned' => "bool", + ], + 'rows' => [ + [1,1,1], + [1,3,0], + [2,1,1], + [2,3,1], + [3,2,1], + ], + ], 'arsse_articles' => [ 'columns' => [ 'id' => "int", @@ -108,9 +135,9 @@ trait SeriesSubscription { ], ]; $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"),''], + [1,"http://example.com/feed1", "Ook", "", "",strtotime("now"),strtotime("now"),''], + [2,"http://example.com/feed2", "eek", "", "",strtotime("now - 1 hour"),strtotime("now - 1 hour"),'http://example.com/favicon.ico'], + [3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour"),strtotime("now + 1 hour"),''], ]; // initialize a partial mock of the Database object to later manipulate the feedUpdate method Arsse::$db = Phake::partialMock(Database::class, static::$drv); @@ -133,7 +160,7 @@ trait SeriesSubscription { 'arsse_subscriptions' => ['id','owner','feed'], ]); $state['arsse_subscriptions']['rows'][] = [$subID,$this->user,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddASubscriptionToANewFeed() { @@ -150,7 +177,7 @@ trait SeriesSubscription { ]); $state['arsse_feeds']['rows'][] = [$feedID,$url,"",""]; $state['arsse_subscriptions']['rows'][] = [$subID,$this->user,$feedID]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddASubscriptionToANewFeedViaDiscovery() { @@ -168,7 +195,7 @@ trait SeriesSubscription { ]); $state['arsse_feeds']['rows'][] = [$feedID,$discovered,"",""]; $state['arsse_subscriptions']['rows'][] = [$subID,$this->user,$feedID]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddASubscriptionToAnInvalidFeed() { @@ -184,7 +211,7 @@ trait SeriesSubscription { 'arsse_feeds' => ['id','url','username','password'], 'arsse_subscriptions' => ['id','owner','feed'], ]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); $this->assertException("invalidUrl", "Feed"); throw $e; } @@ -211,7 +238,7 @@ trait SeriesSubscription { 'arsse_subscriptions' => ['id','owner','feed'], ]); array_shift($state['arsse_subscriptions']['rows']); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAMissingSubscription() { @@ -350,15 +377,15 @@ trait SeriesSubscription { 'arsse_subscriptions' => ['id','owner','feed','title','folder','pinned','order_type'], ]); $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,"Ook Ook",3,0,0]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); Arsse::$db->subscriptionPropertiesSet($this->user, 1, [ 'title' => null, ]); $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // making no changes is a valid result Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['unhinged' => true]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMoveASubscriptionToAMissingFolder() { @@ -447,4 +474,39 @@ trait SeriesSubscription { $this->assertException("notAuthorized", "User", "ExceptionAuthz"); Arsse::$db->subscriptionFavicon(-2112, $user); } + + public function testListTheTagsOfASubscription() { + $this->assertEquals([1,2], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1)); + $this->assertEquals([2], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 3)); + $this->assertEquals(["Fascinating","Interesting"], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1, true)); + $this->assertEquals(["Fascinating"], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 3, true)); + } + + public function testListTheTagsOfAMissingSubscription() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->subscriptionTagsGet($this->user, 101); + } + + public function testListTheTagsOfASubscriptionWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1); + } + + public function testGetRefreshTimeOfASubscription() { + $user = "john.doe@example.com"; + $this->assertTime(strtotime("now + 1 hour"), Arsse::$db->subscriptionRefreshed($user)); + $this->assertTime(strtotime("now - 1 hour"), Arsse::$db->subscriptionRefreshed($user, 1)); + } + + public function testGetRefreshTimeOfAMissingSubscription() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + $this->assertTime(strtotime("now - 1 hour"), Arsse::$db->subscriptionRefreshed("john.doe@example.com", 2)); + } + + public function testGetRefreshTimeOfASubscriptionWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + $this->assertTime(strtotime("now + 1 hour"), Arsse::$db->subscriptionRefreshed("john.doe@example.com")); + } } diff --git a/tests/cases/Database/SeriesTag.php b/tests/cases/Database/SeriesTag.php new file mode 100644 index 0000000..ddd52cd --- /dev/null +++ b/tests/cases/Database/SeriesTag.php @@ -0,0 +1,425 @@ +data = [ + 'arsse_users' => [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + ], + 'rows' => [ + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], + ["john.doe@example.org", ""], + ["john.doe@example.net", ""], + ], + ], + 'arsse_feeds' => [ + 'columns' => [ + 'id' => "int", + 'url' => "str", + 'title' => "str", + ], + 'rows' => [ + [1,"http://example.com/1",""], + [2,"http://example.com/2",""], + [3,"http://example.com/3","Feed Title"], + [4,"http://example.com/4",""], + [5,"http://example.com/5","Feed Title"], + [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", + 'title' => "str", + ], + 'rows' => [ + [1, "john.doe@example.com", 1,"Lord of Carrots"], + [2, "john.doe@example.com", 2,null], + [3, "john.doe@example.com", 3,"Subscription Title"], + [4, "john.doe@example.com", 4,null], + [5, "john.doe@example.com",10,null], + [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,null], + [13,"john.doe@example.net", 3,null], + [14,"john.doe@example.net", 4,null], + ] + ], + 'arsse_tags' => [ + '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"], + ], + ], + 'arsse_tag_members' => [ + 'columns' => [ + 'tag' => "int", + 'subscription' => "int", + 'assigned' => "bool", + ], + 'rows' => [ + [1,1,1], + [1,3,0], + [1,5,1], + [2,1,1], + [2,3,1], + [2,5,1], + ], + ], + ]; + $this->checkTags = ['arsse_tags' => ["id","owner","name"]]; + $this->checkMembers = ['arsse_tag_members' => ["tag","subscription","assigned"]]; + $this->user = "john.doe@example.com"; + } + + protected function tearDownSeriesTag() { + unset($this->data, $this->checkTags, $this->checkMembers, $this->user); + } + + public function testAddATag() { + $user = "john.doe@example.com"; + $tagID = $this->nextID("arsse_tags"); + $this->assertSame($tagID, Arsse::$db->tagAdd($user, ['name' => "Entertaining"])); + Phake::verify(Arsse::$user)->authorize($user, "tagAdd"); + $state = $this->primeExpectations($this->data, $this->checkTags); + $state['arsse_tags']['rows'][] = [$tagID, $user, "Entertaining"]; + $this->compareExpectations(static::$drv, $state); + } + + public function testAddADuplicateTag() { + $this->assertException("constraintViolation", "Db", "ExceptionInput"); + Arsse::$db->tagAdd("john.doe@example.com", ['name' => "Interesting"]); + } + + public function testAddATagWithAMissingName() { + $this->assertException("missing", "Db", "ExceptionInput"); + Arsse::$db->tagAdd("john.doe@example.com", []); + } + + public function testAddATagWithABlankName() { + $this->assertException("missing", "Db", "ExceptionInput"); + Arsse::$db->tagAdd("john.doe@example.com", ['name' => ""]); + } + + public function testAddATagWithAWhitespaceName() { + $this->assertException("whitespace", "Db", "ExceptionInput"); + Arsse::$db->tagAdd("john.doe@example.com", ['name' => " "]); + } + + public function testAddATagWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagAdd("john.doe@example.com", ['name' => "Boring"]); + } + + public function testListTags() { + $exp = [ + ['id' => 2, 'name' => "Fascinating"], + ['id' => 1, 'name' => "Interesting"], + ['id' => 4, 'name' => "Lonely"], + ]; + $this->assertResult($exp, Arsse::$db->tagList("john.doe@example.com")); + $exp = [ + ['id' => 3, 'name' => "Boring"], + ]; + $this->assertResult($exp, Arsse::$db->tagList("jane.doe@example.com")); + $exp = []; + $this->assertResult($exp, Arsse::$db->tagList("jane.doe@example.com", false)); + Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagList"); + } + + public function testListTagsWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagList("john.doe@example.com"); + } + + public function testRemoveATag() { + $this->assertTrue(Arsse::$db->tagRemove("john.doe@example.com", 1)); + Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagRemove"); + $state = $this->primeExpectations($this->data, $this->checkTags); + array_shift($state['arsse_tags']['rows']); + $this->compareExpectations(static::$drv, $state); + } + + public function testRemoveATagByName() { + $this->assertTrue(Arsse::$db->tagRemove("john.doe@example.com", "Interesting", true)); + Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagRemove"); + $state = $this->primeExpectations($this->data, $this->checkTags); + array_shift($state['arsse_tags']['rows']); + $this->compareExpectations(static::$drv, $state); + } + + public function testRemoveAMissingTag() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagRemove("john.doe@example.com", 2112); + } + + public function testRemoveAnInvalidTag() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagRemove("john.doe@example.com", -1); + } + + public function testRemoveAnInvalidTagByName() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagRemove("john.doe@example.com", [], true); + } + + public function testRemoveATagOfTheWrongOwner() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagRemove("john.doe@example.com", 3); // tag ID 3 belongs to Jane + } + + public function testRemoveATagWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagRemove("john.doe@example.com", 1); + } + + public function testGetThePropertiesOfATag() { + $exp = [ + 'id' => 2, + 'name' => "Fascinating", + ]; + $this->assertArraySubset($exp, Arsse::$db->tagPropertiesGet("john.doe@example.com", 2)); + $this->assertArraySubset($exp, Arsse::$db->tagPropertiesGet("john.doe@example.com", "Fascinating", true)); + Phake::verify(Arsse::$user, Phake::times(2))->authorize("john.doe@example.com", "tagPropertiesGet"); + } + + public function testGetThePropertiesOfAMissingTag() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesGet("john.doe@example.com", 2112); + } + + public function testGetThePropertiesOfAnInvalidTag() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesGet("john.doe@example.com", -1); + } + + public function testGetThePropertiesOfAnInvalidTagByName() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesGet("john.doe@example.com", [], true); + } + + public function testGetThePropertiesOfATagOfTheWrongOwner() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesGet("john.doe@example.com", 3); // tag ID 3 belongs to Jane + } + + public function testGetThePropertiesOfATagWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagPropertiesGet("john.doe@example.com", 1); + } + + public function testMakeNoChangesToATag() { + $this->assertFalse(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, [])); + } + + public function testRenameATag() { + $this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Curious"])); + Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagPropertiesSet"); + $state = $this->primeExpectations($this->data, $this->checkTags); + $state['arsse_tags']['rows'][0][2] = "Curious"; + $this->compareExpectations(static::$drv, $state); + } + + public function testRenameATagByName() { + $this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", "Interesting", ['name' => "Curious"], true)); + Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagPropertiesSet"); + $state = $this->primeExpectations($this->data, $this->checkTags); + $state['arsse_tags']['rows'][0][2] = "Curious"; + $this->compareExpectations(static::$drv, $state); + } + + public function testRenameATagToTheEmptyString() { + $this->assertException("missing", "Db", "ExceptionInput"); + $this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => ""])); + } + + public function testRenameATagToWhitespaceOnly() { + $this->assertException("whitespace", "Db", "ExceptionInput"); + $this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => " "])); + } + + public function testRenameATagToAnInvalidValue() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + $this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => []])); + } + + public function testCauseATagCollision() { + $this->assertException("constraintViolation", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Fascinating"]); + } + + public function testSetThePropertiesOfAMissingTag() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", 2112, ['name' => "Exciting"]); + } + + public function testSetThePropertiesOfAnInvalidTag() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", -1, ['name' => "Exciting"]); + } + + public function testSetThePropertiesOfAnInvalidTagByName() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", [], ['name' => "Exciting"], true); + } + + public function testSetThePropertiesOfATagForTheWrongOwner() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", 3, ['name' => "Exciting"]); // tag ID 3 belongs to Jane + } + + public function testSetThePropertiesOfATagWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Exciting"]); + } + + public function testListTaggedSubscriptions() { + $exp = [1,5]; + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1)); + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Interesting", true)); + $exp = [1,3,5]; + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 2)); + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Fascinating", true)); + $exp = []; + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 4)); + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Lonely", true)); + } + + public function testListTaggedSubscriptionsForAMissingTag() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 3); + } + + public function testListTaggedSubscriptionsForAnInvalidTag() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagSubscriptionsGet("john.doe@example.com", -1); + } + + public function testListTaggedSubscriptionsWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1); + } + + public function testApplyATagToSubscriptions() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [3,4]); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][1][2] = 1; + $state['arsse_tag_members']['rows'][] = [1,4,1]; + $this->compareExpectations(static::$drv, $state); + } + + public function testClearATagFromSubscriptions() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [1,3], Database::ASSOC_REMOVE); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][0][2] = 0; + $this->compareExpectations(static::$drv, $state); + } + + public function testApplyATagToSubscriptionsByName() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [3,4], Database::ASSOC_ADD, true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][1][2] = 1; + $state['arsse_tag_members']['rows'][] = [1,4,1]; + $this->compareExpectations(static::$drv, $state); + } + + public function testClearATagFromSubscriptionsByName() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [1,3], Database::ASSOC_REMOVE, true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][0][2] = 0; + $this->compareExpectations(static::$drv, $state); + } + + public function testApplyATagToNoSubscriptionsByName() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [], Database::ASSOC_ADD, true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $this->compareExpectations(static::$drv, $state); + } + + public function testClearATagFromNoSubscriptionsByName() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [], Database::ASSOC_REMOVE, true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $this->compareExpectations(static::$drv, $state); + } + + public function testReplaceSubscriptionsOfATag() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [3,4], Database::ASSOC_REPLACE); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][0][2] = 0; + $state['arsse_tag_members']['rows'][1][2] = 1; + $state['arsse_tag_members']['rows'][2][2] = 0; + $state['arsse_tag_members']['rows'][] = [1,4,1]; + $this->compareExpectations(static::$drv, $state); + } + + public function testPurgeSubscriptionsOfATag() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [], Database::ASSOC_REPLACE); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][0][2] = 0; + $state['arsse_tag_members']['rows'][2][2] = 0; + $this->compareExpectations(static::$drv, $state); + } + + public function testApplyATagToSubscriptionsWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [3,4]); + } + + public function testSummarizeTags() { + $exp = [ + ['id' => 1, 'name' => "Interesting", 'subscription' => 1], + ['id' => 1, 'name' => "Interesting", 'subscription' => 5], + ['id' => 2, 'name' => "Fascinating", 'subscription' => 1], + ['id' => 2, 'name' => "Fascinating", 'subscription' => 3], + ['id' => 2, 'name' => "Fascinating", 'subscription' => 5], + ]; + $this->assertResult($exp, Arsse::$db->tagSummarize("john.doe@example.com")); + } + + public function testSummarizeTagsWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagSummarize("john.doe@example.com"); + } +} diff --git a/tests/cases/Database/SeriesToken.php b/tests/cases/Database/SeriesToken.php new file mode 100644 index 0000000..ef223df --- /dev/null +++ b/tests/cases/Database/SeriesToken.php @@ -0,0 +1,140 @@ +data = [ + 'arsse_users' => [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + ], + 'rows' => [ + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], + ], + ], + 'arsse_tokens' => [ + 'columns' => [ + 'id' => "str", + 'class' => "str", + 'user' => "str", + 'expires' => "datetime", + ], + 'rows' => [ + ["80fa94c1a11f11e78667001e673b2560", "fever.login", "jane.doe@example.com", $faroff], + ["27c6de8da13311e78667001e673b2560", "fever.login", "jane.doe@example.com", $past], // expired + ["ab3b3eb8a13311e78667001e673b2560", "class.class", "jane.doe@example.com", null], + ["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $future], + ], + ], + ]; + } + + protected function tearDownSeriesToken() { + unset($this->data); + } + + public function testLookUpAValidToken() { + $exp1 = [ + 'id' => "80fa94c1a11f11e78667001e673b2560", + 'class' => "fever.login", + 'user' => "jane.doe@example.com" + ]; + $exp2 = [ + 'id' => "da772f8fa13c11e78667001e673b2560", + 'class' => "class.class", + 'user' => "john.doe@example.com" + ]; + $this->assertArraySubset($exp1, Arsse::$db->tokenLookup("fever.login", "80fa94c1a11f11e78667001e673b2560")); + $this->assertArraySubset($exp2, Arsse::$db->tokenLookup("class.class", "da772f8fa13c11e78667001e673b2560")); + // token lookup should not check authorization + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertArraySubset($exp1, Arsse::$db->tokenLookup("fever.login", "80fa94c1a11f11e78667001e673b2560")); + } + + public function testLookUpAMissingToken() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tokenLookup("class", "thisTokenDoesNotExist"); + } + + public function testLookUpAnExpiredToken() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tokenLookup("fever.login", "27c6de8da13311e78667001e673b2560"); + } + + public function testLookUpATokenOfTheWrongClass() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tokenLookup("some.class", "80fa94c1a11f11e78667001e673b2560"); + } + + public function testCreateAToken() { + $user = "jane.doe@example.com"; + $state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "class", "expires", "user"]]); + $id = Arsse::$db->tokenCreate($user, "fever.login"); + $state['arsse_tokens']['rows'][] = [$id, "fever.login", null, $user]; + $this->compareExpectations(static::$drv, $state); + $id = Arsse::$db->tokenCreate($user, "fever.login", null, new \DateTime("2020-01-01T00:00:00Z")); + $state['arsse_tokens']['rows'][] = [$id, "fever.login", "2020-01-01 00:00:00", $user]; + $this->compareExpectations(static::$drv, $state); + Arsse::$db->tokenCreate($user, "fever.login", "token!", new \DateTime("2021-01-01T00:00:00Z")); + $state['arsse_tokens']['rows'][] = ["token!", "fever.login", "2021-01-01 00:00:00", $user]; + $this->compareExpectations(static::$drv, $state); + } + + public function testCreateATokenForAMissingUser() { + $this->assertException("doesNotExist", "User"); + Arsse::$db->tokenCreate("fever.login", "jane.doe@example.biz"); + } + + public function testCreateATokenWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tokenCreate("fever.login", "jane.doe@example.com"); + } + + public function testRevokeAToken() { + $user = "jane.doe@example.com"; + $id = "80fa94c1a11f11e78667001e673b2560"; + $this->assertTrue(Arsse::$db->tokenRevoke($user, "fever.login", $id)); + $state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "expires", "user"]]); + unset($state['arsse_tokens']['rows'][0]); + $this->compareExpectations(static::$drv, $state); + // revoking a token which does not exist is not an error + $this->assertFalse(Arsse::$db->tokenRevoke($user, "fever.login", $id)); + } + + public function testRevokeAllTokens() { + $user = "jane.doe@example.com"; + $state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "expires", "user"]]); + $this->assertTrue(Arsse::$db->tokenRevoke($user, "fever.login")); + unset($state['arsse_tokens']['rows'][0]); + unset($state['arsse_tokens']['rows'][1]); + $this->compareExpectations(static::$drv, $state); + $this->assertTrue(Arsse::$db->tokenRevoke($user, "class.class")); + unset($state['arsse_tokens']['rows'][2]); + $this->compareExpectations(static::$drv, $state); + // revoking tokens which do not exist is not an error + $this->assertFalse(Arsse::$db->tokenRevoke($user, "unknown.class")); + } + + public function testRevokeATokenWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tokenRevoke("jane.doe@example.com", "fever.login"); + } +} diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php index 49c324b..8395edc 100644 --- a/tests/cases/Database/SeriesUser.php +++ b/tests/cases/Database/SeriesUser.php @@ -17,13 +17,11 @@ trait SeriesUser { '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], + ["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW'], // password is hash of "secret" + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], ]; @@ -38,7 +36,7 @@ trait SeriesUser { $this->assertFalse(Arsse::$db->userExists("jane.doe@example.org")); Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "userExists"); Phake::verify(Arsse::$user)->authorize("jane.doe@example.org", "userExists"); - $this->compareExpectations($this->data); + $this->compareExpectations(static::$drv, $this->data); } public function testCheckThatAUserExistsWithoutAuthority() { @@ -68,9 +66,9 @@ trait SeriesUser { public function testAddANewUser() { $this->assertTrue(Arsse::$db->userAdd("john.doe@example.org", "")); Phake::verify(Arsse::$user)->authorize("john.doe@example.org", "userAdd"); - $state = $this->primeExpectations($this->data, ['arsse_users' => ['id','name','rights']]); - $state['arsse_users']['rows'][] = ["john.doe@example.org", null, 0]; - $this->compareExpectations($state); + $state = $this->primeExpectations($this->data, ['arsse_users' => ['id']]); + $state['arsse_users']['rows'][] = ["john.doe@example.org"]; + $this->compareExpectations(static::$drv, $state); } public function testAddAnExistingUser() { @@ -89,7 +87,7 @@ trait SeriesUser { Phake::verify(Arsse::$user)->authorize("admin@example.net", "userRemove"); $state = $this->primeExpectations($this->data, ['arsse_users' => ['id']]); array_shift($state['arsse_users']['rows']); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAMissingUser() { @@ -129,6 +127,13 @@ trait SeriesUser { $this->assertTrue(password_verify($pass, $hash), "Failed verifying password of $user '$pass' against hash '$hash'."); } + public function testUnsetAPassword() { + $user = "john.doe@example.com"; + $this->assertEquals("", Arsse::$db->userPasswordGet($user)); + $this->assertTrue(Arsse::$db->userPasswordSet($user, null)); + $this->assertNull(Arsse::$db->userPasswordGet($user)); + } + public function testSetThePasswordOfAMissingUser() { $this->assertException("doesNotExist", "User"); Arsse::$db->userPasswordSet("john.doe@example.org", "secret"); diff --git a/tests/cases/DatabaseDrivers/MySQL.php b/tests/cases/DatabaseDrivers/MySQL.php index 27dcb4a..3d14d2e 100644 --- a/tests/cases/DatabaseDrivers/MySQL.php +++ b/tests/cases/DatabaseDrivers/MySQL.php @@ -18,7 +18,7 @@ trait MySQL { protected static $stringOutput = true; public static function dbInterface() { - $d = new \mysqli(Arsse::$conf->dbMySQLHost, Arsse::$conf->dbMySQLUser, Arsse::$conf->dbMySQLPass, Arsse::$conf->dbMySQLDb, Arsse::$conf->dbMySQLPort); + $d = @new \mysqli(Arsse::$conf->dbMySQLHost, Arsse::$conf->dbMySQLUser, Arsse::$conf->dbMySQLPass, Arsse::$conf->dbMySQLDb, Arsse::$conf->dbMySQLPort); if ($d->connect_errno) { return; } diff --git a/tests/cases/DatabaseDrivers/SQLite3.php b/tests/cases/DatabaseDrivers/SQLite3.php index e927d41..880539a 100644 --- a/tests/cases/DatabaseDrivers/SQLite3.php +++ b/tests/cases/DatabaseDrivers/SQLite3.php @@ -28,7 +28,7 @@ trait SQLite3 { } public static function dbTableList($db): array { - $listTables = "SELECT name from sqlite_master where type = 'table' and name like 'arsse_%'"; + $listTables = "SELECT name from sqlite_master where type = 'table' and name like 'arsse^_%' escape '^'"; if ($db instanceof Driver) { $tables = $db->query($listTables)->getAll(); $tables = sizeof($tables) ? array_column($tables, "name") : []; diff --git a/tests/cases/Db/BaseDriver.php b/tests/cases/Db/BaseDriver.php index 682c688..677339b 100644 --- a/tests/cases/Db/BaseDriver.php +++ b/tests/cases/Db/BaseDriver.php @@ -94,6 +94,7 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { public function testTranslateAToken() { $this->assertRegExp("/^[a-z][a-z0-9]*$/i", $this->drv->sqlToken("greatest")); $this->assertRegExp("/^\"?[a-z][a-z0-9_\-]*\"?$/i", $this->drv->sqlToken("nocase")); + $this->assertRegExp("/^[a-z][a-z0-9]*$/i", $this->drv->sqlToken("like")); $this->assertSame("distinct", $this->drv->sqlToken("distinct")); } @@ -377,4 +378,13 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { $this->drv->savepointUndo(); $this->assertTrue($this->exec(str_replace("#", "3", $this->setVersion))); } + + public function testProduceAStringLiteral() { + $this->assertSame("'It''s a string!'", $this->drv->literalString("It's a string!")); + } + + public function testPerformMaintenance() { + // this performs maintenance in the absence of tables; see BaseUpdate.php for another test with tables + $this->assertTrue($this->drv->maintenance()); + } } diff --git a/tests/cases/Db/BaseStatement.php b/tests/cases/Db/BaseStatement.php index bd719aa..f62c3e8 100644 --- a/tests/cases/Db/BaseStatement.php +++ b/tests/cases/Db/BaseStatement.php @@ -68,7 +68,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { /** @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 and native MySQL is currently unknown"); + $this->markTestIncomplete("Correct handling of binary data with PostgreSQL is not currently implemented"); } if ($exp === "null") { $query = "SELECT (? is null) as pass"; @@ -143,7 +143,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { '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 datetime' => [null, "strict datetime", "'0001-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"], @@ -153,7 +153,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { '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 datetime' => [true, "strict datetime", "'0001-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"], @@ -163,7 +163,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { '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 datetime' => [false, "strict datetime", "'0001-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"], @@ -213,7 +213,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { '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 datetime' => ["Random string", "strict datetime", "'0001-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"], @@ -223,7 +223,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { '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 datetime' => ["\u{e9}", "strict datetime", "'0001-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"], @@ -306,7 +306,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { '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 datetime' => [chr(233).chr(233), "strict datetime", "'0001-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'"], diff --git a/tests/cases/Db/BaseUpdate.php b/tests/cases/Db/BaseUpdate.php index 2780684..e9bc10d 100644 --- a/tests/cases/Db/BaseUpdate.php +++ b/tests/cases/Db/BaseUpdate.php @@ -130,4 +130,9 @@ class BaseUpdate extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertException("updateTooNew", "Db"); $this->drv->schemaUpdate(-1, $this->base); } + + public function testPerformMaintenance() { + $this->drv->schemaUpdate(Database::SCHEMA_VERSION); + $this->assertTrue($this->drv->maintenance()); + } } diff --git a/tests/cases/ImportExport/TestFile.php b/tests/cases/ImportExport/TestFile.php new file mode 100644 index 0000000..5a85bb6 --- /dev/null +++ b/tests/cases/ImportExport/TestFile.php @@ -0,0 +1,131 @@ +proc = \Phake::partialMock(AbstractImportExport::class); + \Phake::when($this->proc)->export->thenReturn("EXPORT_FILE"); + \Phake::when($this->proc)->import->thenReturn(true); + $this->vfs = vfsStream::setup("root", null, [ + 'exportGoodFile' => "", + 'exportGoodDir' => [], + 'exportBadFile' => "", + 'exportBadDir' => [], + 'importGoodFile' => "GOOD_FILE", + 'importBadFile' => "", + ]); + $this->path = $this->vfs->url()."/"; + // make the "bad" entries inaccessible + chmod($this->path."exportBadFile", 0000); + chmod($this->path."exportBadDir", 0000); + chmod($this->path."importBadFile", 0000); + } + + public function tearDown() { + $this->path = null; + $this->vfs = null; + $this->proc = null; + self::clearData(); + } + + /** @dataProvider provideFileExports */ + public function testExportToAFile(string $file, string $user, bool $flat, $exp) { + $path = $this->path.$file; + try { + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + $this->proc->exportFile($path, $user, $flat); + } else { + $this->assertSame($exp, $this->proc->exportFile($path, $user, $flat)); + $this->assertSame("EXPORT_FILE", $this->vfs->getChild($file)->getContent()); + } + } finally { + \Phake::verify($this->proc)->export($user, $flat); + } + } + + public function provideFileExports() { + $createException = new Exception("fileUncreatable"); + $writeException = new Exception("fileUnwritable"); + return [ + ["exportGoodFile", "john.doe@example.com", true, true], + ["exportGoodFile", "john.doe@example.com", false, true], + ["exportGoodFile", "jane.doe@example.com", true, true], + ["exportGoodFile", "jane.doe@example.com", false, true], + ["exportGoodDir/file", "john.doe@example.com", true, true], + ["exportGoodDir/file", "john.doe@example.com", false, true], + ["exportGoodDir/file", "jane.doe@example.com", true, true], + ["exportGoodDir/file", "jane.doe@example.com", false, true], + ["exportBadFile", "john.doe@example.com", true, $writeException], + ["exportBadFile", "john.doe@example.com", false, $writeException], + ["exportBadFile", "jane.doe@example.com", true, $writeException], + ["exportBadFile", "jane.doe@example.com", false, $writeException], + ["exportBadDir/file", "john.doe@example.com", true, $createException], + ["exportBadDir/file", "john.doe@example.com", false, $createException], + ["exportBadDir/file", "jane.doe@example.com", true, $createException], + ["exportBadDir/file", "jane.doe@example.com", false, $createException], + ]; + } + + /** @dataProvider provideFileImports */ + public function testImportFromAFile(string $file, string $user, bool $flat, bool $replace, $exp) { + $path = $this->path.$file; + try { + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + $this->proc->importFile($path, $user, $flat, $replace); + } else { + $this->assertSame($exp, $this->proc->importFile($path, $user, $flat, $replace)); + } + } finally { + \Phake::verify($this->proc, \Phake::times((int) ($exp === true)))->import($user, "GOOD_FILE", $flat, $replace); + } + } + + public function provideFileImports() { + $missingException = new Exception("fileMissing"); + $permissionException = new Exception("fileUnreadable"); + return [ + ["importGoodFile", "john.doe@example.com", true, true, true], + ["importBadFile", "john.doe@example.com", true, true, $permissionException], + ["importNonFile", "john.doe@example.com", true, true, $missingException], + ["importGoodFile", "john.doe@example.com", true, false, true], + ["importBadFile", "john.doe@example.com", true, false, $permissionException], + ["importNonFile", "john.doe@example.com", true, false, $missingException], + ["importGoodFile", "john.doe@example.com", false, true, true], + ["importBadFile", "john.doe@example.com", false, true, $permissionException], + ["importNonFile", "john.doe@example.com", false, true, $missingException], + ["importGoodFile", "john.doe@example.com", false, false, true], + ["importBadFile", "john.doe@example.com", false, false, $permissionException], + ["importNonFile", "john.doe@example.com", false, false, $missingException], + ["importGoodFile", "jane.doe@example.com", true, true, true], + ["importBadFile", "jane.doe@example.com", true, true, $permissionException], + ["importNonFile", "jane.doe@example.com", true, true, $missingException], + ["importGoodFile", "jane.doe@example.com", true, false, true], + ["importBadFile", "jane.doe@example.com", true, false, $permissionException], + ["importNonFile", "jane.doe@example.com", true, false, $missingException], + ["importGoodFile", "jane.doe@example.com", false, true, true], + ["importBadFile", "jane.doe@example.com", false, true, $permissionException], + ["importNonFile", "jane.doe@example.com", false, true, $missingException], + ["importGoodFile", "jane.doe@example.com", false, false, true], + ["importBadFile", "jane.doe@example.com", false, false, $permissionException], + ["importNonFile", "jane.doe@example.com", false, false, $missingException], + ]; + } +} diff --git a/tests/cases/ImportExport/TestImportExport.php b/tests/cases/ImportExport/TestImportExport.php new file mode 100644 index 0000000..f5452a5 --- /dev/null +++ b/tests/cases/ImportExport/TestImportExport.php @@ -0,0 +1,264 @@ + ["id", "owner", "parent", "name"], + 'arsse_feeds' => ["id", "url", "title"], + 'arsse_subscriptions' => ["id", "owner", "folder", "feed", "title"], + 'arsse_tags' => ["id", "owner", "name"], + 'arsse_tag_members' => ["tag", "subscription", "assigned"], + ]; + + public function setUp() { + self::clearData(); + // create a mock user manager + Arsse::$user = \Phake::mock(\JKingWeb\Arsse\User::class); + \Phake::when(Arsse::$user)->exists->thenReturn(true); + \Phake::when(Arsse::$user)->authorize->thenReturn(true); + // create a mock Import/Export processor + $this->proc = \Phake::partialMock(AbstractImportExport::class); + // initialize an SQLite memeory database + static::setConf(); + try { + $this->drv = Driver::create(); + } catch (\JKingWeb\Arsse\Db\Exception $e) { + $this->markTestSkipped("An SQLite database is required for this test"); + } + // create the database interface with the suitable driver and apply the latest schema + Arsse::$db = new Database($this->drv); + Arsse::$db->driverSchemaUpdate(); + $this->data = [ + 'arsse_users' => [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + ], + 'rows' => [ + ["john.doe@example.com", ""], + ["jane.doe@example.com", ""], + ], + ], + 'arsse_folders' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'parent' => "int", + 'name' => "str", + ], + 'rows' => [ + [1, "john.doe@example.com", null, "Science"], + [2, "john.doe@example.com", 1, "Rocketry"], + [3, "john.doe@example.com", null, "Politics"], + [4, "john.doe@example.com", null, "Photography"], + [5, "john.doe@example.com", 3, "Local"], + [6, "john.doe@example.com", 3, "National"], + ], + ], + 'arsse_feeds' => [ + 'columns' => [ + 'id' => "int", + 'url' => "str", + 'title' => "str", + ], + 'rows' => [ + [1, "http://localhost:8000/Import/nasa-jpl", "NASA JPL"], + [2, "http://localhost:8000/Import/torstar", "Toronto Star"], + [3, "http://localhost:8000/Import/ars", "Ars Technica"], + [4, "http://localhost:8000/Import/cbc", "CBC News"], + [5, "http://localhost:8000/Import/citizen", "Ottawa Citizen"], + [6, "http://localhost:8000/Import/eurogamer", "Eurogamer"], + ], + ], + 'arsse_subscriptions' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'folder' => "int", + 'feed' => "int", + 'title' => "str", + ], + 'rows' => [ + [1, "john.doe@example.com", 2, 1, "NASA JPL"], + [2, "john.doe@example.com", 5, 2, "Toronto Star"], + [3, "john.doe@example.com", 1, 3, "Ars Technica"], + [4, "john.doe@example.com", 6, 4, "CBC News"], + [5, "john.doe@example.com", 6, 5, "Ottawa Citizen"], + [6, "john.doe@example.com", null, 6, "Eurogamer"], + ], + ], + 'arsse_tags' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'name' => "str", + ], + 'rows' => [ + [1, "john.doe@example.com", "canada"], + [2, "john.doe@example.com", "frequent"], + [3, "john.doe@example.com", "gaming"], + [4, "john.doe@example.com", "news"], + [5, "john.doe@example.com", "tech"], + [6, "john.doe@example.com", "toronto"], + ], + ], + 'arsse_tag_members' => [ + 'columns' => [ + 'tag' => "int", + 'subscription' => "int", + 'assigned' => "bool", + ], + 'rows' => [ + [1, 2, 1], + [1, 4, 1], + [1, 5, 1], + [2, 3, 1], + [2, 6, 1], + [3, 6, 1], + [4, 2, 1], + [4, 4, 1], + [4, 5, 1], + [5, 1, 1], + [5, 3, 1], + [6, 2, 1], + ], + ], + ]; + $this->primeDatabase($this->drv, $this->data); + } + + public function tearDown() { + $this->drv = null; + $this->proc = null; + self::clearData(); + } + + public function testImportForAMissingUser() { + \Phake::when(Arsse::$user)->exists->thenReturn(false); + $this->assertException("doesNotExist", "User"); + $this->proc->import("john.doe@example.com", "", false, false); + } + + public function testImportWithInvalidFolder() { + $in = [[ + ], [1 => + ['id' => 1, 'name' => "", 'parent' => 0], + ]]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->assertException("invalidFolderName", "ImportExport"); + $this->proc->import("john.doe@example.com", "", false, false); + } + + public function testImportWithDuplicateFolder() { + $in = [[ + ], [1 => + ['id' => 1, 'name' => "New", 'parent' => 0], + ['id' => 2, 'name' => "New", 'parent' => 0], + ]]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->assertException("invalidFolderCopy", "ImportExport"); + $this->proc->import("john.doe@example.com", "", false, false); + } + + public function testMakeNoEffectiveChanges() { + $in = [[ + ['url' => "http://localhost:8000/Import/nasa-jpl", 'title' => "NASA JPL", 'folder' => 3, 'tags' => ["tech"]], + ['url' => "http://localhost:8000/Import/ars", 'title' => "Ars Technica", 'folder' => 2, 'tags' => ["frequent", "tech"]], + ['url' => "http://localhost:8000/Import/torstar", 'title' => "Toronto Star", 'folder' => 5, 'tags' => ["news", "canada", "toronto"]], + ['url' => "http://localhost:8000/Import/citizen", 'title' => "Ottawa Citizen", 'folder' => 6, 'tags' => ["news", "canada"]], + ['url' => "http://localhost:8000/Import/eurogamer", 'title' => "Eurogamer", 'folder' => 0, 'tags' => ["gaming", "frequent"]], + ['url' => "http://localhost:8000/Import/cbc", 'title' => "CBC News", 'folder' => 6, 'tags' => ["news", "canada"]], + ], [1 => + ['id' => 1, 'name' => "Photography", 'parent' => 0], + ['id' => 2, 'name' => "Science", 'parent' => 0], + ['id' => 3, 'name' => "Rocketry", 'parent' => 2], + ['id' => 4, 'name' => "Politics", 'parent' => 0], + ['id' => 5, 'name' => "Local", 'parent' => 4], + ['id' => 6, 'name' => "National", 'parent' => 4], + ]]; + \Phake::when($this->proc)->parse->thenReturn($in); + $exp = $this->primeExpectations($this->data, $this->checkTables); + $this->proc->import("john.doe@example.com", "", false, false); + $this->compareExpectations($this->drv, $exp); + $this->proc->import("john.doe@example.com", "", false, true); + $this->compareExpectations($this->drv, $exp); + } + + public function testModifyASubscription() { + $in = [[ + ['url' => "http://localhost:8000/Import/nasa-jpl", 'title' => "NASA JPL", 'folder' => 3, 'tags' => ["tech"]], + ['url' => "http://localhost:8000/Import/ars", 'title' => "Ars Technica", 'folder' => 2, 'tags' => ["frequent", "tech"]], + ['url' => "http://localhost:8000/Import/torstar", 'title' => "Toronto Star", 'folder' => 5, 'tags' => ["news", "canada", "toronto"]], + ['url' => "http://localhost:8000/Import/citizen", 'title' => "Ottawa Citizen", 'folder' => 6, 'tags' => ["news", "canada"]], + ['url' => "http://localhost:8000/Import/eurogamer", 'title' => "Eurogamer", 'folder' => 0, 'tags' => ["gaming", "frequent"]], + ['url' => "http://localhost:8000/Import/cbc", 'title' => "CBC", 'folder' => 0, 'tags' => ["news", "canada"]], // moved to root and renamed + ], [1 => + ['id' => 1, 'name' => "Photography", 'parent' => 0], + ['id' => 2, 'name' => "Science", 'parent' => 0], + ['id' => 3, 'name' => "Rocketry", 'parent' => 2], + ['id' => 4, 'name' => "Politics", 'parent' => 0], + ['id' => 5, 'name' => "Local", 'parent' => 4], + ['id' => 6, 'name' => "National", 'parent' => 4], + ]]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->proc->import("john.doe@example.com", "", false, true); + $exp = $this->primeExpectations($this->data, $this->checkTables); + $exp['arsse_subscriptions']['rows'][3] = [4, "john.doe@example.com", null, 4, "CBC"]; + $this->compareExpectations($this->drv, $exp); + } + + public function testImportAFeed() { + $in = [[ + ['url' => "http://localhost:8000/Import/some-feed", 'title' => "Some Feed", 'folder' => 0, 'tags' => ["frequent", "cryptic"]], //one existing tag and one new one + ], []]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->proc->import("john.doe@example.com", "", false, false); + $exp = $this->primeExpectations($this->data, $this->checkTables); + $exp['arsse_feeds']['rows'][] = [7, "http://localhost:8000/Import/some-feed", "Some feed"]; // author-supplied and user-supplied titles differ + $exp['arsse_subscriptions']['rows'][] = [7, "john.doe@example.com", null, 7, "Some Feed"]; + $exp['arsse_tags']['rows'][] = [7, "john.doe@example.com", "cryptic"]; + $exp['arsse_tag_members']['rows'][] = [2, 7, 1]; + $exp['arsse_tag_members']['rows'][] = [7, 7, 1]; + $this->compareExpectations($this->drv, $exp); + } + + public function testImportAFeedWithAnInvalidTag() { + $in = [[ + ['url' => "http://localhost:8000/Import/some-feed", 'title' => "Some Feed", 'folder' => 0, 'tags' => [""]], + ], []]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->assertException("invalidTagName", "ImportExport"); + $this->proc->import("john.doe@example.com", "", false, false); + } + + public function testReplaceData() { + $in = [[ + ['url' => "http://localhost:8000/Import/some-feed", 'title' => "Some Feed", 'folder' => 1, 'tags' => ["frequent", "cryptic"]], + ], [1 => + ['id' => 1, 'name' => "Photography", 'parent' => 0], + ]]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->proc->import("john.doe@example.com", "", false, true); + $exp = $this->primeExpectations($this->data, $this->checkTables); + $exp['arsse_feeds']['rows'][] = [7, "http://localhost:8000/Import/some-feed", "Some feed"]; // author-supplied and user-supplied titles differ + $exp['arsse_subscriptions']['rows'] = [[7, "john.doe@example.com", 4, 7, "Some Feed"]]; + $exp['arsse_tags']['rows'] = [[2, "john.doe@example.com", "frequent"], [7, "john.doe@example.com", "cryptic"]]; + $exp['arsse_tag_members']['rows'] = [[2, 7, 1], [7, 7, 1]]; + $exp['arsse_folders']['rows'] = [[4, "john.doe@example.com", null, "Photography"]]; + $this->compareExpectations($this->drv, $exp); + } +} diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php new file mode 100644 index 0000000..503211c --- /dev/null +++ b/tests/cases/ImportExport/TestOPML.php @@ -0,0 +1,165 @@ + */ +class TestOPML extends \JKingWeb\Arsse\Test\AbstractTest { + protected $folders = [ + ['id' => 5, 'parent' => 3, 'children' => 0, 'feeds' => 1, 'name' => "Local"], + ['id' => 6, 'parent' => 3, 'children' => 0, 'feeds' => 2, 'name' => "National"], + ['id' => 4, 'parent' => null, 'children' => 0, 'feeds' => 0, 'name' => "Photography"], + ['id' => 3, 'parent' => null, 'children' => 2, 'feeds' => 0, 'name' => "Politics"], + ['id' => 2, 'parent' => 1, 'children' => 0, 'feeds' => 1, 'name' => "Rocketry"], + ['id' => 1, 'parent' => null, 'children' => 1, 'feeds' => 1, 'name' => "Science"], + ]; + protected $subscriptions = [ + ['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'err_msg' => 'argh', 'title' => 'Ars Technica', 'url' => "http://example.com/3", 'favicon' => 'http://example.com/3.png'], + ['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'err_msg' => '', 'title' => 'CBC News', 'url' => "http://example.com/4", 'favicon' => 'http://example.com/4.png'], + ['id' => 6, 'folder' => null, 'top_folder' => null, 'unread' => 0, 'updated' => "2010-02-12 20:08:47", 'err_msg' => '', 'title' => 'Eurogamer', 'url' => "http://example.com/6", 'favicon' => 'http://example.com/6.png'], + ['id' => 1, 'folder' => 2, 'top_folder' => 1, 'unread' => 5, 'updated' => "2017-09-15 22:54:16", 'err_msg' => '', 'title' => 'NASA JPL', 'url' => "http://example.com/1", 'favicon' => null], + ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'err_msg' => '', 'title' => 'Ottawa Citizen', 'url' => "http://example.com/5", 'favicon' => ''], + ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'err_msg' => 'oops', 'title' => 'Toronto Star', 'url' => "http://example.com/2", 'favicon' => 'http://example.com/2.png'], + ]; + protected $tags = [ + ['id' => 1, 'name' => "Canada", 'subscription' => 2], + ['id' => 1, 'name' => "Canada", 'subscription' => 4], + ['id' => 1, 'name' => "Canada", 'subscription' => 5], + ['id' => 2, 'name' => "Politics", 'subscription' => 4], + ['id' => 2, 'name' => "Politics", 'subscription' => 5], + ['id' => 3, 'name' => "Science, etc", 'subscription' => 1], + ['id' => 3, 'name' => "Science, etc", 'subscription' => 3], + // Eurogamer is untagged + ]; + protected $serialization = << + + + + + + + + + + + + + + + + + + + + + + +OPML_EXPORT_SERIALIZATION; + protected $serializationFlat = << + + + + + + + + + + + +OPML_EXPORT_SERIALIZATION; + + public function setUp() { + self::clearData(); + Arsse::$db = \Phake::mock(\JKingWeb\Arsse\Database::class); + Arsse::$user = \Phake::mock(\JKingWeb\Arsse\User::class); + \Phake::when(Arsse::$user)->exists->thenReturn(true); + } + + public function testExportToOpml() { + \Phake::when(Arsse::$db)->folderList("john.doe@example.com")->thenReturn(new Result($this->folders)); + \Phake::when(Arsse::$db)->subscriptionList("john.doe@example.com")->thenReturn(new Result($this->subscriptions)); + \Phake::when(Arsse::$db)->tagSummarize("john.doe@example.com")->thenReturn(new Result($this->tags)); + $this->assertXmlStringEqualsXmlString($this->serialization, (new OPML)->export("john.doe@example.com")); + } + + public function testExportToFlatOpml() { + \Phake::when(Arsse::$db)->folderList("john.doe@example.com")->thenReturn(new Result($this->folders)); + \Phake::when(Arsse::$db)->subscriptionList("john.doe@example.com")->thenReturn(new Result($this->subscriptions)); + \Phake::when(Arsse::$db)->tagSummarize("john.doe@example.com")->thenReturn(new Result($this->tags)); + $this->assertXmlStringEqualsXmlString($this->serializationFlat, (new OPML)->export("john.doe@example.com", true)); + } + + public function testExportToOpmlAMissingUser() { + \Phake::when(Arsse::$user)->exists->thenReturn(false); + $this->assertException("doesNotExist", "User"); + (new OPML)->export("john.doe@example.com"); + } + + /** @dataProvider provideParserData */ + public function testParseOpmlForImport(string $file, bool $flat, $exp) { + $data = file_get_contents(\JKingWeb\Arsse\DOCROOT."Import/OPML/$file"); + // set up a partial mock to make the ImportExport::parse() method visible + $parser = \Phake::makeVisible(\Phake::partialMock(OPML::class)); + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + $parser->parse($data, $flat); + } else { + $this->assertSame($exp, $parser->parse($data, $flat)); + } + } + + public function provideParserData() { + return [ + ["BrokenXML.opml", false, new Exception("invalidSyntax")], + ["BrokenOPML.1.opml", false, new Exception("invalidSemantics")], + ["BrokenOPML.2.opml", false, new Exception("invalidSemantics")], + ["BrokenOPML.3.opml", false, new Exception("invalidSemantics")], + ["BrokenOPML.4.opml", false, new Exception("invalidSemantics")], + ["Empty.1.opml", false, [[], []]], + ["Empty.2.opml", false, [[], []]], + ["Empty.3.opml", false, [[], []]], + ["FeedsOnly.opml", false, [[ + ['url' => "http://example.com/1", 'title' => "Feed 1", 'folder' => 0, 'tags' => []], + ['url' => "http://example.com/2", 'title' => "", 'folder' => 0, 'tags' => []], + ['url' => "http://example.com/3", 'title' => "", 'folder' => 0, 'tags' => []], + ['url' => "http://example.com/4", 'title' => "", 'folder' => 0, 'tags' => []], + ['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee"]], + ['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee", "whoo"]], + ], []]], + ["FoldersOnly.opml", true, [[], []]], + ["FoldersOnly.opml", false, [[], [1 => + ['id' => 1, 'name' => "Folder 1", 'parent' => 0], + ['id' => 2, 'name' => "Folder 2", 'parent' => 0], + ['id' => 3, 'name' => "Also a folder", 'parent' => 2], + ['id' => 4, 'name' => "Still a folder", 'parent' => 2], + ['id' => 5, 'name' => "Folder 5", 'parent' => 4], + ['id' => 6, 'name' => "Folder 6", 'parent' => 0], + ]]], + ["MixedContent.opml", false, [[ + ['url' => "https://www.jpl.nasa.gov/multimedia/rss/news.xml", 'title' => "NASA JPL", 'folder' => 3, 'tags' => ["tech"]], + ['url' => "http://feeds.arstechnica.com/arstechnica/index/", 'title' => "Ars Technica", 'folder' => 2, 'tags' => ["frequent", "tech"]], + ['url' => "https://www.thestar.com/content/thestar/feed.RSSManagerServlet.topstories.rss", 'title' => "Toronto Star", 'folder' => 5, 'tags' => ["news", "canada", "toronto"]], + ['url' => "http://rss.canada.com/get/?F239", 'title' => "Ottawa Citizen", 'folder' => 6, 'tags' => ["news", "canada"]], + ['url' => "https://www.eurogamer.net/?format=rss", 'title' => "Eurogamer", 'folder' => 0, 'tags' => ["gaming", "frequent"]], + ], [1 => + ['id' => 1, 'name' => "Photography", 'parent' => 0], + ['id' => 2, 'name' => "Science", 'parent' => 0], + ['id' => 3, 'name' => "Rocketry", 'parent' => 2], + ['id' => 4, 'name' => "Politics", 'parent' => 0], + ['id' => 5, 'name' => "Local", 'parent' => 4], + ['id' => 6, 'name' => "National", 'parent' => 4], + ]]], + ]; + } +} diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index 07d6adb..f32f11e 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -6,14 +6,15 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Misc; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; +use JKingWeb\Arsse\Misc\ValueInfo; -/** @covers \JKingWeb\Arsse\Misc\Context */ +/** @covers \JKingWeb\Arsse\Context\Context */ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { public function testVerifyInitialState() { $c = new Context; foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { - if ($m->isConstructor() || $m->isStatic()) { + if ($m->isStatic() || strpos($m->name, "__") === 0) { continue; } $method = $m->name; @@ -28,8 +29,15 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'limit' => 10, 'offset' => 5, 'folder' => 42, + 'folders' => [12,22], 'folderShallow' => 42, + 'foldersShallow' => [0,1], + 'tag' => 44, + 'tags' => [44, 2112], + 'tagName' => "XLIV", + 'tagNames' => ["XLIV", "MMCXII"], 'subscription' => 2112, + 'subscriptions' => [44, 2112], 'article' => 255, 'edition' => 65535, 'latestArticle' => 47, @@ -45,14 +53,21 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'editions' => [1,2], 'articles' => [1,2], 'label' => 2112, + 'labels' => [2112, 1984], 'labelName' => "Rush", + 'labelNames' => ["Rush", "Orwell"], 'labelled' => true, 'annotated' => true, + 'searchTerms' => ["foo", "bar"], + 'annotationTerms' => ["foo", "bar"], + 'titleTerms' => ["foo", "bar"], + 'authorTerms' => ["foo", "bar"], + 'not' => (new Context)->subscription(5), ]; $times = ['modifiedSince','notModifiedSince','markedSince','notMarkedSince']; $c = new Context; foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { - if ($m->isConstructor() || $m->isStatic()) { + if ($m->isStatic() || strpos($m->name, "__") === 0) { continue; } $method = $m->name; @@ -70,13 +85,45 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } } - public function testCleanArrayValues() { - $methods = ["articles", "editions"]; - $in = [1, "2", 3.5, 3.0, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; - $out = [1,2, 3]; + public function testCleanIdArrayValues() { + $methods = ["articles", "editions", "tags", "labels", "subscriptions"]; + $in = [1, "2", 3.5, 4.0, 4, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; + $out = [1, 2, 4]; $c = new Context; foreach ($methods as $method) { $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); } } + + public function testCleanFolderIdArrayValues() { + $methods = ["folders", "foldersShallow"]; + $in = [1, "2", 3.5, 4.0, 4, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; + $out = [1, 2, 4, 0]; + $c = new Context; + foreach ($methods as $method) { + $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); + } + } + + public function testCleanStringArrayValues() { + $methods = ["searchTerms", "annotationTerms", "titleTerms", "authorTerms", "tagNames", "labelNames"]; + $now = new \DateTime; + $in = [1, 3.0, "ook", 0, true, false, null, $now, ""]; + $out = ["1", "3", "ook", "0", valueInfo::normalize($now, ValueInfo::T_STRING)]; + $c = new Context; + foreach ($methods as $method) { + $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); + } + } + + public function testCloneAContext() { + $c1 = new Context; + $c2 = clone $c1; + $this->assertEquals($c1, $c2); + $this->assertEquals($c1->not, $c2->not); + $this->assertNotSame($c1, $c2); + $this->assertNotSame($c1->not, $c2->not); + $this->assertSame($c1, $c1->not->article(null)); + $this->assertSame($c2, $c2->not->article(null)); + } } diff --git a/tests/cases/REST/Fever/PDO/TestAPI.php b/tests/cases/REST/Fever/PDO/TestAPI.php new file mode 100644 index 0000000..02caa3d --- /dev/null +++ b/tests/cases/REST/Fever/PDO/TestAPI.php @@ -0,0 +1,13 @@ + + * @group optional */ +class TestAPI extends \JKingWeb\Arsse\TestCase\REST\Fever\TestAPI { + use \JKingWeb\Arsse\Test\PDOTest; +} diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php new file mode 100644 index 0000000..3393dde --- /dev/null +++ b/tests/cases/REST/Fever/TestAPI.php @@ -0,0 +1,514 @@ + */ +class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { + /** @var \JKingWeb\Arsse\REST\Fever\API */ + protected $h; + + protected $articles = [ + 'db' => [ + [ + 'id' => 101, + 'url' => 'http://example.com/1', + 'title' => 'Article title 1', + 'author' => '', + 'content' => '

Article content 1

', + 'published_date' => '2000-01-01 00:00:00', + 'unread' => 1, + 'starred' => 0, + 'subscription' => 8, + ], + [ + 'id' => 102, + 'url' => 'http://example.com/2', + 'title' => 'Article title 2', + 'author' => '', + 'content' => '

Article content 2

', + 'published_date' => '2000-01-02 00:00:00', + 'unread' => 0, + 'starred' => 0, + 'subscription' => 8, + ], + [ + 'id' => 103, + 'url' => 'http://example.com/3', + 'title' => 'Article title 3', + 'author' => '', + 'content' => '

Article content 3

', + 'published_date' => '2000-01-03 00:00:00', + 'unread' => 1, + 'starred' => 1, + 'subscription' => 9, + ], + [ + 'id' => 104, + 'url' => 'http://example.com/4', + 'title' => 'Article title 4', + 'author' => '', + 'content' => '

Article content 4

', + 'published_date' => '2000-01-04 00:00:00', + 'unread' => 0, + 'starred' => 1, + 'subscription' => 9, + ], + [ + 'id' => 105, + 'url' => 'http://example.com/5', + 'title' => 'Article title 5', + 'author' => '', + 'content' => '

Article content 5

', + 'published_date' => '2000-01-05 00:00:00', + 'unread' => 1, + 'starred' => 0, + 'subscription' => 10, + ], + ], + 'rest' => [ + [ + 'id' => 101, + 'feed_id' => 8, + 'title' => 'Article title 1', + 'author' => '', + 'html' => '

Article content 1

', + 'url' => 'http://example.com/1', + 'is_saved' => 0, + 'is_read' => 0, + 'created_on_time' => 946684800, + ], + [ + 'id' => 102, + 'feed_id' => 8, + 'title' => 'Article title 2', + 'author' => '', + 'html' => '

Article content 2

', + 'url' => 'http://example.com/2', + 'is_saved' => 0, + 'is_read' => 1, + 'created_on_time' => 946771200, + ], + [ + 'id' => 103, + 'feed_id' => 9, + 'title' => 'Article title 3', + 'author' => '', + 'html' => '

Article content 3

', + 'url' => 'http://example.com/3', + 'is_saved' => 1, + 'is_read' => 0, + 'created_on_time' => 946857600, + ], + [ + 'id' => 104, + 'feed_id' => 9, + 'title' => 'Article title 4', + 'author' => '', + 'html' => '

Article content 4

', + 'url' => 'http://example.com/4', + 'is_saved' => 1, + 'is_read' => 1, + 'created_on_time' => 946944000, + ], + [ + 'id' => 105, + 'feed_id' => 10, + 'title' => 'Article title 5', + 'author' => '', + 'html' => '

Article content 5

', + 'url' => 'http://example.com/5', + 'is_saved' => 0, + 'is_read' => 0, + 'created_on_time' => 947030400, + ], + ], + ]; + protected function v($value) { + return $value; + } + + protected function req($dataGet, $dataPost = "", string $method = "POST", string $type = null, string $url = "", string $user = null): ServerRequest { + $url = "/fever/".$url; + $type = $type ?? "application/x-www-form-urlencoded"; + $server = [ + 'REQUEST_METHOD' => $method, + 'REQUEST_URI' => $url, + 'HTTP_CONTENT_TYPE' => $type, + ]; + $req = new ServerRequest($server, [], $url, $method, "php://memory", ['Content-Type' => $type]); + if (!is_array($dataGet)) { + parse_str($dataGet, $dataGet); + } + $req = $req->withRequestTarget($url)->withQueryParams($dataGet); + if (is_array($dataPost)) { + $req = $req->withParsedBody($dataPost); + } else { + parse_str($dataPost, $arr); + $req = $req->withParsedBody($arr); + } + if (isset($user)) { + if (strlen($user)) { + $req = $req->withAttribute("authenticated", true)->withAttribute("authenticatedUser", $user); + } else { + $req = $req->withAttribute("authenticationFailed", true); + } + } + return $req; + } + + public function setUp() { + self::clearData(); + self::setConf(); + // create a mock user manager + Arsse::$user = \Phake::mock(User::class); + \Phake::when(Arsse::$user)->auth->thenReturn(true); + Arsse::$user->id = "john.doe@example.com"; + // create a mock database interface + Arsse::$db = \Phake::mock(Database::class); + \Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(Transaction::class)); + \Phake::when(Arsse::$db)->tokenLookup->thenReturn(['user' => "john.doe@example.com"]); + // instantiate the handler as a partial mock to simplify testing + $this->h = \Phake::partialMock(API::class); + \Phake::when($this->h)->baseResponse->thenReturn([]); + } + + public function tearDown() { + self::clearData(); + } + + /** @dataProvider provideTokenAuthenticationRequests */ + public function testAuthenticateAUserToken(bool $httpRequired, bool $tokenEnforced, string $httpUser = null, array $dataPost, array $dataGet, ResponseInterface $exp) { + self::setConf([ + 'userHTTPAuthRequired' => $httpRequired, + 'userSessionEnforced' => $tokenEnforced, + ], true); + Arsse::$user->id = null; + \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("subjectMissing")); + \Phake::when(Arsse::$db)->tokenLookup("fever.login", "validtoken")->thenReturn(['user' => "jane.doe@example.com"]); + // test only the authentication process + \Phake::when($this->h)->baseResponse->thenReturnCallback(function(bool $authenticated) { + return ['auth' => (int) $authenticated]; + }); + \Phake::when($this->h)->processRequest->thenReturnCallback(function($out, $G, $P) { + return $out; + }); + $act = $this->h->dispatch($this->req($dataGet, $dataPost, "POST", null, "", $httpUser)); + $this->assertMessage($exp, $act); + } + + public function provideTokenAuthenticationRequests() { + $success = new JsonResponse(['auth' => 1]); + $failure = new JsonResponse(['auth' => 0]); + $denied = new EmptyResponse(401); + return [ + [false, true, null, [], ['api' => null], $failure], + [false, false, null, [], ['api' => null], $failure], + [true, true, null, [], ['api' => null], $denied], + [true, false, null, [], ['api' => null], $denied], + [false, true, "", [], ['api' => null], $denied], + [false, false, "", [], ['api' => null], $denied], + [true, true, "", [], ['api' => null], $denied], + [true, false, "", [], ['api' => null], $denied], + [false, true, null, [], ['api' => null, 'api_key' => "validToken"], $failure], + [false, false, null, [], ['api' => null, 'api_key' => "validToken"], $failure], + [true, true, null, [], ['api' => null, 'api_key' => "validToken"], $denied], + [true, false, null, [], ['api' => null, 'api_key' => "validToken"], $denied], + [false, true, "", [], ['api' => null, 'api_key' => "validToken"], $denied], + [false, false, "", [], ['api' => null, 'api_key' => "validToken"], $denied], + [true, true, "", [], ['api' => null, 'api_key' => "validToken"], $denied], + [true, false, "", [], ['api' => null, 'api_key' => "validToken"], $denied], + [false, true, "validUser", [], ['api' => null, 'api_key' => "validToken"], $failure], + [false, false, "validUser", [], ['api' => null, 'api_key' => "validToken"], $success], + [true, true, "validUser", [], ['api' => null, 'api_key' => "validToken"], $failure], + [true, false, "validUser", [], ['api' => null, 'api_key' => "validToken"], $success], + [false, true, null, ['api_key' => "validToken"], ['api' => null], $success], + [false, false, null, ['api_key' => "validToken"], ['api' => null], $success], + [true, true, null, ['api_key' => "validToken"], ['api' => null], $denied], + [true, false, null, ['api_key' => "validToken"], ['api' => null], $denied], + [false, true, "", ['api_key' => "validToken"], ['api' => null], $denied], + [false, false, "", ['api_key' => "validToken"], ['api' => null], $denied], + [true, true, "", ['api_key' => "validToken"], ['api' => null], $denied], + [true, false, "", ['api_key' => "validToken"], ['api' => null], $denied], + [false, true, "validUser", ['api_key' => "validToken"], ['api' => null], $success], + [false, false, "validUser", ['api_key' => "validToken"], ['api' => null], $success], + [true, true, "validUser", ['api_key' => "validToken"], ['api' => null], $success], + [true, false, "validUser", ['api_key' => "validToken"], ['api' => null], $success], + [false, true, null, ['api_key' => "invalidToken"], ['api' => null], $failure], + [false, false, null, ['api_key' => "invalidToken"], ['api' => null], $failure], + [true, true, null, ['api_key' => "invalidToken"], ['api' => null], $denied], + [true, false, null, ['api_key' => "invalidToken"], ['api' => null], $denied], + [false, true, "", ['api_key' => "invalidToken"], ['api' => null], $denied], + [false, false, "", ['api_key' => "invalidToken"], ['api' => null], $denied], + [true, true, "", ['api_key' => "invalidToken"], ['api' => null], $denied], + [true, false, "", ['api_key' => "invalidToken"], ['api' => null], $denied], + [false, true, "validUser", ['api_key' => "invalidToken"], ['api' => null], $failure], + [false, false, "validUser", ['api_key' => "invalidToken"], ['api' => null], $success], + [true, true, "validUser", ['api_key' => "invalidToken"], ['api' => null], $failure], + [true, false, "validUser", ['api_key' => "invalidToken"], ['api' => null], $success], + ]; + } + + public function testListGroups() { + \Phake::when(Arsse::$db)->tagList(Arsse::$user->id)->thenReturn(new Result([ + ['id' => 1, 'name' => "Fascinating", 'subscriptions' => 2], + ['id' => 2, 'name' => "Interesting", 'subscriptions' => 2], + ['id' => 3, 'name' => "Boring", 'subscriptions' => 0], + ])); + \Phake::when(Arsse::$db)->tagSummarize(Arsse::$user->id)->thenReturn(new Result([ + ['id' => 1, 'name' => "Fascinating", 'subscription' => 1], + ['id' => 1, 'name' => "Fascinating", 'subscription' => 2], + ['id' => 2, 'name' => "Interesting", 'subscription' => 1], + ['id' => 2, 'name' => "Interesting", 'subscription' => 3], + ])); + $exp = new JsonResponse([ + 'groups' => [ + ['id' => 1, 'title' => "Fascinating"], + ['id' => 2, 'title' => "Interesting"], + ['id' => 3, 'title' => "Boring"], + ], + 'feeds_groups' => [ + ['group_id' => 1, 'feed_ids' => "1,2"], + ['group_id' => 2, 'feed_ids' => "1,3"], + ], + ]); + $act = $this->h->dispatch($this->req("api&groups")); + $this->assertMessage($exp, $act); + } + + public function testListFeeds() { + \Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([ + ['id' => 1, 'feed' => 5, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'source' => "http://example.com/", 'edited' => "2019-01-01 21:12:00", 'favicon' => "http://example.com/favicon.ico"], + ['id' => 2, 'feed' => 9, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'source' => "http://example.net/", 'edited' => "1988-06-24 12:21:00", 'favicon' => ""], + ['id' => 3, 'feed' => 1, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'source' => "http://example.org/", 'edited' => "1991-08-12 03:22:00", 'favicon' => "http://example.org/favicon.ico"], + ])); + \Phake::when(Arsse::$db)->tagSummarize(Arsse::$user->id)->thenReturn(new Result([ + ['id' => 1, 'name' => "Fascinating", 'subscription' => 1], + ['id' => 1, 'name' => "Fascinating", 'subscription' => 2], + ['id' => 2, 'name' => "Interesting", 'subscription' => 1], + ['id' => 2, 'name' => "Interesting", 'subscription' => 3], + ])); + $exp = new JsonResponse([ + 'feeds' => [ + ['id' => 1, 'favicon_id' => 0, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'site_url' => "http://example.com/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("2019-01-01T21:12:00Z")], + ['id' => 2, 'favicon_id' => 0, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'site_url' => "http://example.net/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1988-06-24T12:21:00Z")], + ['id' => 3, 'favicon_id' => 0, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'site_url' => "http://example.org/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1991-08-12T03:22:00Z")], + ], + 'feeds_groups' => [ + ['group_id' => 1, 'feed_ids' => "1,2"], + ['group_id' => 2, 'feed_ids' => "1,3"], + ], + ]); + $act = $this->h->dispatch($this->req("api&feeds")); + $this->assertMessage($exp, $act); + } + + /** @dataProvider provideItemListContexts */ + public function testListItems(string $url, Context $c, bool $desc) { + $fields = ["id", "subscription", "title", "author", "content", "url", "starred", "unread", "published_date"]; + $order = [$desc ? "id desc" : "id"]; + \Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->articles['db'])); + \Phake::when(Arsse::$db)->articleCount(Arsse::$user->id)->thenReturn(1024); + $exp = new JsonResponse([ + 'items' => $this->articles['rest'], + 'total_items' => 1024, + ]); + $act = $this->h->dispatch($this->req("api&$url")); + $this->assertMessage($exp, $act); + \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $c, $fields, $order); + } + + public function provideItemListContexts() { + $c = (new Context)->limit(50); + return [ + ["items", (clone $c), false], + ["items&group_ids=1,2,3,4", (clone $c)->tags([1,2,3,4]), false], + ["items&feed_ids=1,2,3,4", (clone $c)->subscriptions([1,2,3,4]), false], + ["items&with_ids=1,2,3,4", (clone $c)->articles([1,2,3,4]), false], + ["items&since_id=1", (clone $c)->oldestArticle(2), false], + ["items&max_id=2", (clone $c)->latestArticle(1), true], + ["items&with_ids=1,2,3,4&max_id=6", (clone $c)->articles([1,2,3,4]), false], + ["items&with_ids=1,2,3,4&since_id=6", (clone $c)->articles([1,2,3,4]), false], + ["items&max_id=3&since_id=6", (clone $c)->latestArticle(2), true], + ["items&feed_ids=1,2,3,4&since_id=6", (clone $c)->subscriptions([1,2,3,4])->oldestArticle(7), false], + ]; + } + + public function testListItemIds() { + $saved = [['id' => 1],['id' => 2],['id' => 3]]; + $unread = [['id' => 4],['id' => 5],['id' => 6]]; + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true))->thenReturn(new Result($saved)); + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread)); + $exp = new JsonResponse([ + 'saved_item_ids' => "1,2,3" + ]); + $this->assertMessage($exp, $this->h->dispatch($this->req("api&saved_item_ids"))); + $exp = new JsonResponse([ + 'unread_item_ids' => "4,5,6" + ]); + $this->assertMessage($exp, $this->h->dispatch($this->req("api&unread_item_ids"))); + } + + public function testListHotLinks() { + // hot links are not actually implemented, so an empty array should be all we get + $exp = new JsonResponse([ + 'links' => [] + ]); + $this->assertMessage($exp, $this->h->dispatch($this->req("api&links"))); + } + + /** @dataProvider provideMarkingContexts */ + public function testSetMarks(string $post, Context $c, array $data, array $out) { + $saved = [['id' => 1],['id' => 2],['id' => 3]]; + $unread = [['id' => 4],['id' => 5],['id' => 6]]; + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true))->thenReturn(new Result($saved)); + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread)); + \Phake::when(Arsse::$db)->articleMark->thenReturn(0); + \Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->article(2112))->thenThrow(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing")); + $exp = new JsonResponse($out); + $act = $this->h->dispatch($this->req("api", $post)); + $this->assertMessage($exp, $act); + if ($c && $data) { + \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $data, $c); + } else { + \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark; + } + } + + public function provideMarkingContexts() { + $markRead = ['read' => true]; + $markUnread = ['read' => false]; + $markSaved = ['starred' => true]; + $markUnsaved = ['starred' => false]; + $listSaved = ['saved_item_ids' => "1,2,3"]; + $listUnread = ['unread_item_ids' => "4,5,6"]; + return [ + ["mark=item&as=read&id=5", (new Context)->article(5), $markRead, $listUnread], + ["mark=item&as=unread&id=42", (new Context)->article(42), $markUnread, $listUnread], + ["mark=item&as=read&id=2112", (new Context)->article(2112), $markRead, $listUnread], // article doesn't exist + ["mark=item&as=saved&id=5", (new Context)->article(5), $markSaved, $listSaved], + ["mark=item&as=unsaved&id=42", (new Context)->article(42), $markUnsaved, $listSaved], + ["mark=feed&as=read&id=5", (new Context)->subscription(5), $markRead, $listUnread], + ["mark=feed&as=unread&id=42", (new Context)->subscription(42), $markUnread, $listUnread], + ["mark=feed&as=saved&id=5", (new Context)->subscription(5), $markSaved, $listSaved], + ["mark=feed&as=unsaved&id=42", (new Context)->subscription(42), $markUnsaved, $listSaved], + ["mark=group&as=read&id=5", (new Context)->tag(5), $markRead, $listUnread], + ["mark=group&as=unread&id=42", (new Context)->tag(42), $markUnread, $listUnread], + ["mark=group&as=saved&id=5", (new Context)->tag(5), $markSaved, $listSaved], + ["mark=group&as=unsaved&id=42", (new Context)->tag(42), $markUnsaved, $listSaved], + ["mark=item&as=invalid&id=42", new Context, [], []], + ["mark=invalid&as=unread&id=42", new Context, [], []], + ["mark=group&as=read&id=0", (new Context), $markRead, $listUnread], + ["mark=group&as=unread&id=0", (new Context), $markUnread, $listUnread], + ["mark=group&as=saved&id=0", (new Context), $markSaved, $listSaved], + ["mark=group&as=unsaved&id=0", (new Context), $markUnsaved, $listSaved], + ["mark=group&as=read&id=-1", (new Context)->not->folder(0), $markRead, $listUnread], + ["mark=group&as=unread&id=-1", (new Context)->not->folder(0), $markUnread, $listUnread], + ["mark=group&as=saved&id=-1", (new Context)->not->folder(0), $markSaved, $listSaved], + ["mark=group&as=unsaved&id=-1", (new Context)->not->folder(0), $markUnsaved, $listSaved], + ["mark=group&as=read&id=-1&before=946684800", (new Context)->not->folder(0)->notMarkedSince("2000-01-01T00:00:00Z"), $markRead, $listUnread], + ["mark=item&as=unread", new Context, [], []], + ["mark=item&id=6", new Context, [], []], + ["as=unread&id=6", new Context, [], []], + ]; + } + + /** @dataProvider provideInvalidRequests */ + public function testSendInvalidRequests(ServerRequest $req, ResponseInterface $exp) { + $this->assertMessage($exp, $this->h->dispatch($req)); + } + + public function provideInvalidRequests() { + return [ + 'Not an API request' => [$this->req(""), new EmptyResponse(404)], + 'Wrong method' => [$this->req("api", "", "GET"), new EmptyResponse(405, ['Allow' => "OPTIONS,POST"])], + 'Wrong content type' => [$this->req("api", "", "POST", "application/json"), new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"])], + ]; + } + + public function testMakeABaseQuery() { + $this->h = \Phake::partialMock(API::class); + \Phake::when($this->h)->logIn->thenReturn(true); + \Phake::when(Arsse::$db)->subscriptionRefreshed(Arsse::$user->id)->thenReturn(new \DateTimeImmutable("2000-01-01T00:00:00Z")); + $exp = new JsonResponse([ + 'api_version' => API::LEVEL, + 'auth' => 1, + 'last_refreshed_on_time' => 946684800, + ]); + $act = $this->h->dispatch($this->req("api")); + $this->assertMessage($exp, $act); + \Phake::when(Arsse::$db)->subscriptionRefreshed(Arsse::$user->id)->thenReturn(null); // no subscriptions + $exp = new JsonResponse([ + 'api_version' => API::LEVEL, + 'auth' => 1, + 'last_refreshed_on_time' => null, + ]); + $act = $this->h->dispatch($this->req("api")); + $this->assertMessage($exp, $act); + \Phake::when($this->h)->logIn->thenReturn(false); + $exp = new JsonResponse([ + 'api_version' => API::LEVEL, + 'auth' => 0, + ]); + $act = $this->h->dispatch($this->req("api")); + $this->assertMessage($exp, $act); + } + + public function testUndoReadMarks() { + $unread = [['id' => 4],['id' => 5],['id' => 6]]; + $out = ['unread_item_ids' => "4,5,6"]; + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->thenReturn(new Result([['marked_date' => "2000-01-01 00:00:00"]])); + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread)); + \Phake::when(Arsse::$db)->articleMark->thenReturn(0); + $exp = new JsonResponse($out); + $act = $this->h->dispatch($this->req("api", ['unread_recently_read' => 1])); + $this->assertMessage($exp, $act); + \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, ['read' => false], (new Context)->unread(false)->markedSince("1999-12-31T23:59:45Z")); + \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->thenReturn(new Result([])); + $act = $this->h->dispatch($this->req("api", ['unread_recently_read' => 1])); + $this->assertMessage($exp, $act); + \Phake::verify(Arsse::$db)->articleMark; // only called one time, above + } + + public function testOutputToXml() { + \Phake::when($this->h)->processRequest->thenReturn([ + 'items' => $this->articles['rest'], + 'total_items' => 1024, + ]); + $exp = new XmlResponse("1018Article title 1<p>Article content 1</p>http://example.com/1009466848001028Article title 2<p>Article content 2</p>http://example.com/2019467712001039Article title 3<p>Article content 3</p>http://example.com/3109468576001049Article title 4<p>Article content 4</p>http://example.com/41194694400010510Article title 5<p>Article content 5</p>http://example.com/5009470304001024"); + $act = $this->h->dispatch($this->req("api=xml")); + $this->assertMessage($exp, $act); + } + + public function testListFeedIcons() { + $act = $this->h->dispatch($this->req("api&favicons")); + $exp = new JsonResponse(['favicons' => [['id' => 0, 'data' => API::GENERIC_ICON_TYPE.",".API::GENERIC_ICON_DATA]]]); + $this->assertMessage($exp, $act); + } + + public function testAnswerOptionsRequest() { + $act = $this->h->dispatch($this->req("api", "", "OPTIONS")); + $exp = new EmptyResponse(204, [ + 'Allow' => "POST", + 'Accept' => "application/x-www-form-urlencoded", + ]); + $this->assertMessage($exp, $act); + } +} diff --git a/tests/cases/REST/Fever/TestUser.php b/tests/cases/REST/Fever/TestUser.php new file mode 100644 index 0000000..c185647 --- /dev/null +++ b/tests/cases/REST/Fever/TestUser.php @@ -0,0 +1,94 @@ + */ +class TestUser extends \JKingWeb\Arsse\Test\AbstractTest { + protected $u; + + public function setUp() { + self::clearData(); + self::setConf(); + // create a mock user manager + Arsse::$user = \Phake::mock(User::class); + \Phake::when(Arsse::$user)->auth->thenReturn(true); + // create a mock database interface + Arsse::$db = \Phake::mock(Database::class); + \Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(Transaction::class)); + // instantiate the handler + $this->u = new FeverUser(); + } + + public function tearDown() { + self::clearData(); + } + + /** @dataProvider providePasswordCreations */ + public function testRegisterAUserPassword(string $user, string $password = null, $exp) { + \Phake::when(Arsse::$user)->generatePassword->thenReturn("RANDOM_PASSWORD"); + \Phake::when(Arsse::$db)->tokenCreate->thenReturnCallback(function($user, $class, $id = null) { + return $id ?? "RANDOM_TOKEN"; + }); + \Phake::when(Arsse::$db)->tokenCreate("john.doe@example.org", $this->anything(), $this->anything())->thenThrow(new UserException("doesNotExist")); + try { + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + $this->u->register($user, $password); + } else { + $this->assertSame($exp, $this->u->register($user, $password)); + } + } finally { + \Phake::verify(Arsse::$db)->tokenRevoke($user, "fever.login"); + \Phake::verify(Arsse::$db)->tokenCreate($user, "fever.login", md5($user.":".($password ?? "RANDOM_PASSWORD"))); + } + } + + public function providePasswordCreations() { + return [ + ["jane.doe@example.com", "secret", "secret"], + ["jane.doe@example.com", "superman", "superman"], + ["jane.doe@example.com", null, "RANDOM_PASSWORD"], + ["john.doe@example.org", null, new UserException("doesNotExist")], + ["john.doe@example.net", null, "RANDOM_PASSWORD"], + ["john.doe@example.net", "secret", "secret"], + ]; + } + + public function testUnregisterAUser() { + \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(3); + $this->assertTrue($this->u->unregister("jane.doe@example.com")); + \Phake::verify(Arsse::$db)->tokenRevoke("jane.doe@example.com", "fever.login"); + \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(0); + $this->assertFalse($this->u->unregister("john.doe@example.com")); + \Phake::verify(Arsse::$db)->tokenRevoke("john.doe@example.com", "fever.login"); + } + + /** @dataProvider provideUserAuthenticationRequests */ + public function testAuthenticateAUserName(string $user, string $password, bool $exp) { + \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("constraintViolation")); + \Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("jane.doe@example.com:secret"))->thenReturn(['user' => "jane.doe@example.com"]); + \Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("john.doe@example.com:superman"))->thenReturn(['user' => "john.doe@example.com"]); + $this->assertSame($exp, $this->u->authenticate($user, $password)); + } + + public function provideUserAuthenticationRequests() { + return [ + ["jane.doe@example.com", "secret", true], + ["jane.doe@example.com", "superman", false], + ["john.doe@example.com", "secret", false], + ["john.doe@example.com", "superman", true], + ]; + } +} diff --git a/tests/cases/REST/NextCloudNews/TestV1_2.php b/tests/cases/REST/NextCloudNews/TestV1_2.php index f35e21e..52291cb 100644 --- a/tests/cases/REST/NextCloudNews/TestV1_2.php +++ b/tests/cases/REST/NextCloudNews/TestV1_2.php @@ -13,7 +13,7 @@ use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\Misc\Date; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\NextCloudNews\V1_2; @@ -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(), $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")); + Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->v($this->articles['db']))); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(42), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("idMissing")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(2112), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("idMissing")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(-1), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(-1), $this->anything(), ["edition desc"])->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), $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()); + Phake::verify(Arsse::$db, Phake::times(4))->articleList(Arsse::$user->id, new Context, $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(42), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(2112), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(-1), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(-1), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(10)->oldestEdition(6), $this->anything(), ["edition"]); // offset is one more than specified + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(5)->latestEdition(4), $this->anything(), ["edition desc"]); // offset is one less than specified + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $this->equalTo((new Context)->markedSince($t), 2), $this->anything(), ["edition desc"]); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(5), $this->anything(), ["edition desc"]); } 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), $this->anything()); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true), $this->anything(), ["edition"]); } } diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index bf35a30..b79ea2e 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -14,7 +14,7 @@ use JKingWeb\Arsse\Service; use JKingWeb\Arsse\REST\Request; use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\Misc\Date; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\TinyTinyRSS\API; @@ -1289,18 +1289,18 @@ LONG_STRING; ]; Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, $this->anything(), (new Context)->articles([]), $this->anything())->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, $this->anything(), (new Context)->articles($list[0]), $this->anything())->thenThrow(new ExceptionInput("tooLong")); // data model function limited to 50 items for multiples - Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), true)->thenReturn(42); - Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), true)->thenReturn(47); - Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), false)->thenReturn(5); - Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), false)->thenReturn(2); + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_REMOVE)->thenReturn(42); + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_REMOVE)->thenReturn(47); + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_ADD)->thenReturn(5); + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_ADD)->thenReturn(2); $exp = $this->respGood(['status' => "OK", 'updated' => 89]); $this->assertMessage($exp, $this->req($in[0])); - Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), true); - Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), true); + Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_REMOVE); + Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_REMOVE); $exp = $this->respGood(['status' => "OK", 'updated' => 7]); $this->assertMessage($exp, $this->req($in[1])); - Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), false); - Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), false); + Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_ADD); + Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_ADD); $exp = $this->respGood(['status' => "OK", 'updated' => 0]); $this->assertMessage($exp, $this->req($in[2])); $exp = $this->respErr("INCORRECT_USAGE"); @@ -1749,19 +1749,19 @@ LONG_STRING; Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->v([['id' => 0]]))); Phake::when(Arsse::$db)->articleCount->thenReturn(0); Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1); - $c = (new Context)->reverse(true); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), ["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]]))); + $c = (new Context); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), ["id"], ["edited_date desc"])->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->articleList($this->anything(), $c, ["id"], ["edited_date desc"])->thenReturn(new Result($this->v($this->articles))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), ["id"], ["marked_date desc"])->thenReturn(new Result($this->v([['id' => 1]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 2]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 3]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 4]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 5]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 6]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 7]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 8]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 9]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), ["id"], ["edited_date desc"])->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(), $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]]))); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(false)->markedSince(Date::sub("PT24H")), 2), ["id"], ["marked_date desc"])->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"], ["edited_date desc"])->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"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 1003]]))); $this->assertMessage($out2[$a], $this->req($in2[$a]), "Test $a failed"); } } @@ -1809,6 +1809,8 @@ LONG_STRING; ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "published"], ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "unread"], ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "unread", 'search' => "unread:false"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'search' => "pub:true"], ]; $in2 = [ // simple context tests @@ -1833,6 +1835,7 @@ LONG_STRING; ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'is_cat' => true, 'include_nested' => true], ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'order_by' => "feed_dates"], ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'order_by' => "date_reverse"], + ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'search' => "interesting"], ]; $in3 = [ // time-based context tests @@ -1850,24 +1853,25 @@ LONG_STRING; Phake::when(Arsse::$db)->articleList->thenReturn($this->generateHeadlines(0)); 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), $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)); + $c = (new Context)->limit(200); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), $this->anything(), ["edited_date desc"])->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), $this->anything(), ["marked_date desc"])->thenReturn($this->generateHeadlines(1)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(2)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(3)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(4)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(5)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(6)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(7)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(8)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(9)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(10)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(11)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->labelled(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(12)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(0), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(13)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(42), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(14)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folder(42), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(15)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c), $this->anything(), ["edited_date"])->thenReturn($this->generateHeadlines(16)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->searchTerms(["interesting"]), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(17)); $out2 = [ $this->respErr("INCORRECT_USAGE"), $this->outputHeadlines(11), @@ -1890,6 +1894,7 @@ LONG_STRING; $this->outputHeadlines(15), $this->outputHeadlines(11), // defaulting sorting is not fully implemented $this->outputHeadlines(16), + $this->outputHeadlines(17), ]; $out3 = [ $this->outputHeadlines(1001), @@ -1904,9 +1909,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(), $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)); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(false)->markedSince(Date::sub("PT24H")), 2), $this->anything(), ["marked_date desc"])->thenReturn($this->generateHeadlines(1001)); + Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), 2), $this->anything(), ["edited_date desc"])->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(), ["edited_date desc"])->thenReturn($this->generateHeadlines(1003)); $this->assertMessage($out3[$a], $this->req($in3[$a]), "Test $a failed"); } } @@ -1985,7 +1990,7 @@ LONG_STRING; ]); $this->assertMessage($exp, $test); // test 'include_header' with an erroneous result - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(200)->reverse(true)->subscription(2112), $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(200)->subscription(2112), $this->anything(), ["edited_date desc"])->thenThrow(new ExceptionInput("subjectMissing")); $test = $this->req($in[6]); $exp = $this->respGood([ ['id' => 2112, 'is_cat' => false, 'first_id' => 0], @@ -2000,7 +2005,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), $this->anything())->thenReturn($this->generateHeadlines(1867)); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(1)->subscription(42), $this->anything(), ["edited_date desc"])->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/TestSearch.php b/tests/cases/REST/TinyTinyRSS/TestSearch.php new file mode 100644 index 0000000..c858d1b --- /dev/null +++ b/tests/cases/REST/TinyTinyRSS/TestSearch.php @@ -0,0 +1,125 @@ + ["", new Context], + 'Whitespace only' => [" \n \t", new Context], + 'Simple bare token' => ['OOK', (new Context)->searchTerms(["ook"])], + 'Simple negative bare token' => ['-OOK', (new Context)->not->searchTerms(["ook"])], + 'Simple quoted token' => ['"OOK eek"', (new Context)->searchTerms(["ook eek"])], + 'Simple negative quoted token' => ['"-OOK eek"', (new Context)->not->searchTerms(["ook eek"])], + 'Simple bare tokens' => ['OOK eek', (new Context)->searchTerms(["ook", "eek"])], + 'Simple mixed bare tokens' => ['-OOK eek', (new Context)->not->searchTerms(["ook"])->searchTerms(["eek"])], + 'Unclosed quoted token' => ['"OOK eek', (new Context)->searchTerms(["ook eek"])], + 'Unclosed quoted token 2' => ['"OOK eek" "', (new Context)->searchTerms(["ook eek"])], + 'Broken quoted token 1' => ['"-OOK"eek"', (new Context)->not->searchTerms(["ookeek\""])], + 'Broken quoted token 2' => ['""eek"', (new Context)->searchTerms(["eek\""])], + 'Broken quoted token 3' => ['"-"eek"', (new Context)->not->searchTerms(["eek\""])], + 'Empty quoted token' => ['""', new Context], + 'Simple quoted tokens' => ['"OOK eek" "eek ack"', (new Context)->searchTerms(["ook eek", "eek ack"])], + 'Bare blank tag' => [':ook', (new Context)->searchTerms([":ook"])], + 'Quoted blank tag' => ['":ook"', (new Context)->searchTerms([":ook"])], + 'Bare negative blank tag' => ['-:ook', (new Context)->not->searchTerms([":ook"])], + 'Quoted negative blank tag' => ['"-:ook"', (new Context)->not->searchTerms([":ook"])], + 'Bare valueless blank tag' => [':', (new Context)->searchTerms([":"])], + 'Quoted valueless blank tag' => ['":"', (new Context)->searchTerms([":"])], + 'Bare negative valueless blank tag' => ['-:', (new Context)->not->searchTerms([":"])], + 'Quoted negative valueless blank tag' => ['"-:"', (new Context)->not->searchTerms([":"])], + 'Double negative' => ['--eek', (new Context)->not->searchTerms(["-eek"])], + 'Double negative 2' => ['--@eek', (new Context)->not->searchTerms(["-@eek"])], + 'Double negative 3' => ['"--@eek"', (new Context)->not->searchTerms(["-@eek"])], + 'Double negative 4' => ['"--eek"', (new Context)->not->searchTerms(["-eek"])], + 'Negative before quote' => ['-"ook"', (new Context)->not->searchTerms(["\"ook\""])], + 'Bare unread tag true' => ['UNREAD:true', (new Context)->unread(true)], + 'Bare unread tag false' => ['UNREAD:false', (new Context)->unread(false)], + 'Bare negative unread tag true' => ['-unread:true', (new Context)->unread(false)], + 'Bare negative unread tag false' => ['-unread:false', (new Context)->unread(true)], + 'Quoted unread tag true' => ['"UNREAD:true"', (new Context)->unread(true)], + 'Quoted unread tag false' => ['"UNREAD:false"', (new Context)->unread(false)], + 'Quoted negative unread tag true' => ['"-unread:true"', (new Context)->unread(false)], + 'Quoted negative unread tag false' => ['"-unread:false"', (new Context)->unread(true)], + 'Bare star tag true' => ['STAR:true', (new Context)->starred(true)], + 'Bare star tag false' => ['STAR:false', (new Context)->starred(false)], + 'Bare negative star tag true' => ['-star:true', (new Context)->starred(false)], + 'Bare negative star tag false' => ['-star:false', (new Context)->starred(true)], + 'Quoted star tag true' => ['"STAR:true"', (new Context)->starred(true)], + 'Quoted star tag false' => ['"STAR:false"', (new Context)->starred(false)], + 'Quoted negative star tag true' => ['"-star:true"', (new Context)->starred(false)], + 'Quoted negative star tag false' => ['"-star:false"', (new Context)->starred(true)], + 'Bare note tag true' => ['NOTE:true', (new Context)->annotated(true)], + 'Bare note tag false' => ['NOTE:false', (new Context)->annotated(false)], + 'Bare negative note tag true' => ['-note:true', (new Context)->annotated(false)], + 'Bare negative note tag false' => ['-note:false', (new Context)->annotated(true)], + 'Quoted note tag true' => ['"NOTE:true"', (new Context)->annotated(true)], + 'Quoted note tag false' => ['"NOTE:false"', (new Context)->annotated(false)], + 'Quoted negative note tag true' => ['"-note:true"', (new Context)->annotated(false)], + 'Quoted negative note tag false' => ['"-note:false"', (new Context)->annotated(true)], + 'Bare pub tag true' => ['PUB:true', null], + 'Bare pub tag false' => ['PUB:false', new Context], + 'Bare negative pub tag true' => ['-pub:true', new Context], + 'Bare negative pub tag false' => ['-pub:false', null], + 'Quoted pub tag true' => ['"PUB:true"', null], + 'Quoted pub tag false' => ['"PUB:false"', new Context], + 'Quoted negative pub tag true' => ['"-pub:true"', new Context], + 'Quoted negative pub tag false' => ['"-pub:false"', null], + 'Non-boolean unread tag' => ['unread:maybe', (new Context)->searchTerms(["unread:maybe"])], + 'Non-boolean star tag' => ['star:maybe', (new Context)->searchTerms(["star:maybe"])], + 'Non-boolean pub tag' => ['pub:maybe', (new Context)->searchTerms(["pub:maybe"])], + 'Non-boolean note tag' => ['note:maybe', (new Context)->annotationTerms(["maybe"])], + 'Valueless unread tag' => ['unread:', (new Context)->searchTerms(["unread:"])], + 'Valueless star tag' => ['star:', (new Context)->searchTerms(["star:"])], + 'Valueless pub tag' => ['pub:', (new Context)->searchTerms(["pub:"])], + 'Valueless note tag' => ['note:', (new Context)->searchTerms(["note:"])], + 'Valueless title tag' => ['title:', (new Context)->searchTerms(["title:"])], + 'Valueless author tag' => ['author:', (new Context)->searchTerms(["author:"])], + 'Escaped quote 1' => ['"""I say, Jeeves!"""', (new Context)->searchTerms(["\"i say, jeeves!\""])], + 'Escaped quote 2' => ['"\\"I say, Jeeves!\\""', (new Context)->searchTerms(["\"i say, jeeves!\""])], + 'Escaped quote 3' => ['\\"I say, Jeeves!\\"', (new Context)->searchTerms(["\\\"i", "say,", "jeeves!\\\""])], + 'Escaped quote 4' => ['"\\"\\I say, Jeeves!\\""', (new Context)->searchTerms(["\"\\i say, jeeves!\""])], + 'Escaped quote 5' => ['"\\I say, Jeeves!"', (new Context)->searchTerms(["\\i say, jeeves!"])], + 'Escaped quote 6' => ['"\\"I say, Jeeves!\\', (new Context)->searchTerms(["\"i say, jeeves!\\"])], + 'Escaped quote 7' => ['"\\', (new Context)->searchTerms(["\\"])], + 'Quoted author tag 1' => ['"author:Neal Stephenson"', (new Context)->authorTerms(["neal stephenson"])], + 'Quoted author tag 2' => ['"author:Jo ""Cap\'n Tripps"" Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\" ashburn"])], + 'Quoted author tag 3' => ['"author:Jo \\"Cap\'n Tripps\\" Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\" ashburn"])], + 'Quoted author tag 4' => ['"author:Jo ""Cap\'n Tripps"Ashburn"', (new Context)->authorTerms(["jo \"cap'n trippsashburn\""])], + 'Quoted author tag 5' => ['"author:Jo ""Cap\'n Tripps\ Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\\ ashburn"])], + 'Quoted author tag 6' => ['"author:Neal Stephenson\\', (new Context)->authorTerms(["neal stephenson\\"])], + 'Quoted title tag' => ['"title:Generic title"', (new Context)->titleTerms(["generic title"])], + 'Contradictory booleans' => ['unread:true -unread:true', null], + 'Doubled boolean' => ['unread:true unread:true', (new Context)->unread(true)], + 'Bare blank date' => ['@', new Context], + 'Quoted blank date' => ['"@"', new Context], + 'Bare ISO date' => ['@2019-03-01', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")], + 'Quoted ISO date' => ['"@March 1st, 2019"', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")], + 'Bare negative ISO date' => ['-@2019-03-01', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")], + 'Quoted negative English date' => ['"-@March 1st, 2019"', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")], + 'Invalid date' => ['@Bugaboo', new Context], + 'Escaped quoted date 1' => ['"@""Yesterday" and today', (new Context)->searchTerms(["and", "today"])], + 'Escaped quoted date 2' => ['"@\\"Yesterday" and today', (new Context)->searchTerms(["and", "today"])], + 'Escaped quoted date 3' => ['"@Yesterday\\', new Context], + 'Escaped quoted date 4' => ['"@Yesterday\\and today', new Context], + 'Escaped quoted date 5' => ['"@Yesterday"and today', (new Context)->searchTerms(["today"])], + 'Contradictory dates' => ['@Yesterday @Today', null], + 'Doubled date' => ['"@March 1st, 2019" @2019-03-01', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")], + 'Doubled negative date' => ['"-@March 1st, 2019" -@2019-03-01', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")], + ]; + } + + /** @dataProvider provideSearchStrings */ + public function testApplySearchToContext(string $search, $exp) { + $act = Search::parse($search); + $this->assertEquals($exp, $act); + } +} diff --git a/tests/cases/User/TestInternal.php b/tests/cases/User/TestInternal.php index f7f042d..29d9923 100644 --- a/tests/cases/User/TestInternal.php +++ b/tests/cases/User/TestInternal.php @@ -37,12 +37,13 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest { * @dataProvider provideAuthentication * @group slow */ - public function testAuthenticateAUser(bool $authorized, string $user, string $password, bool $exp) { + public function testAuthenticateAUser(bool $authorized, string $user, $password, bool $exp) { if ($authorized) { Phake::when(Arsse::$db)->userPasswordGet("john.doe@example.com")->thenReturn('$2y$10$1zbqRJhxM8uUjeSBPp4IhO90xrqK0XjEh9Z16iIYEFRV4U.zeAFom'); // hash of "secret" Phake::when(Arsse::$db)->userPasswordGet("jane.doe@example.com")->thenReturn('$2y$10$bK1ljXfTSyc2D.NYvT.Eq..OpehLRXVbglW.23ihVuyhgwJCd.7Im'); // hash of "superman" Phake::when(Arsse::$db)->userPasswordGet("owen.hardy@example.com")->thenReturn(""); Phake::when(Arsse::$db)->userPasswordGet("kira.nerys@example.com")->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist")); + Phake::when(Arsse::$db)->userPasswordGet("007@example.com")->thenReturn(null); } else { Phake::when(Arsse::$db)->userPasswordGet->thenThrow(new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")); } @@ -54,22 +55,26 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest { $jane = "jane.doe@example.com"; $owen = "owen.hardy@example.com"; $kira = "kira.nerys@example.com"; + $bond = "007@example.com"; return [ - [false, $john, "secret", false], - [false, $jane, "superman", false], - [false, $owen, "", false], - [false, $kira, "ashalla", false], - [true, $john, "secret", true], - [true, $jane, "superman", true], - [true, $owen, "", true], - [true, $kira, "ashalla", false], - [true, $john, "top secret", false], - [true, $jane, "clark kent", false], - [true, $owen, "watchmaker", false], - [true, $kira, "singha", false], - [true, $john, "", false], - [true, $jane, "", false], - [true, $kira, "", false], + [false, $john, "secret", false], + [false, $jane, "superman", false], + [false, $owen, "", false], + [false, $kira, "ashalla", false], + [false, $bond, "", false], + [true, $john, "secret", true], + [true, $jane, "superman", true], + [true, $owen, "", true], + [true, $kira, "ashalla", false], + [true, $john, "top secret", false], + [true, $jane, "clark kent", false], + [true, $owen, "watchmaker", false], + [true, $kira, "singha", false], + [true, $john, "", false], + [true, $jane, "", false], + [true, $kira, "", false], + [true, $bond, "for England", false], + [true, $bond, "", false], ]; } @@ -133,4 +138,19 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertSame("superman", (new Driver)->userPasswordSet($john, "superman")); $this->assertSame(null, (new Driver)->userPasswordSet($john, null)); } + + public function testUnsetAPassword() { + $drv = \Phake::partialMock(Driver::class); + \Phake::when($drv)->userExists->thenReturn(true); + Phake::verifyNoFurtherInteraction(Arsse::$db); + $this->assertTrue($drv->userPasswordUnset("john.doe@example.com")); + } + + public function testUnsetAPasswordForAMssingUser() { + $drv = \Phake::partialMock(Driver::class); + \Phake::when($drv)->userExists->thenReturn(false); + Phake::verifyNoFurtherInteraction(Arsse::$db); + $this->assertException("doesNotExist", "User"); + $drv->userPasswordUnset("john.doe@example.com"); + } } diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php index 9496c41..3584f1e 100644 --- a/tests/cases/User/TestUser.php +++ b/tests/cases/User/TestUser.php @@ -297,4 +297,42 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest { [true, $jane, "secret", true, new \JKingWeb\Arsse\User\Exception("doesNotExist")], ]; } + + /** @dataProvider providePasswordClearings */ + public function testClearAPassword(bool $authorized, bool $exists, string $user, $exp) { + Phake::when($this->drv)->authorize->thenReturn($authorized); + Phake::when($this->drv)->userPasswordUnset->thenReturn(true); + Phake::when($this->drv)->userPasswordUnset("jane.doe@example.net", null)->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist")); + Phake::when(Arsse::$db)->userExists->thenReturn($exists); + $u = new User($this->drv); + try { + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + $u->passwordUnset($user); + } else { + $this->assertSame($exp, $u->passwordUnset($user)); + } + } finally { + Phake::verify(Arsse::$db, Phake::times((int) ($authorized && $exists && is_bool($exp))))->userPasswordSet($user, null); + } + } + + public function providePasswordClearings() { + $forbidden = new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized"); + $missing = new \JKingWeb\Arsse\User\Exception("doesNotExist"); + return [ + [false, true, "jane.doe@example.com", $forbidden], + [false, true, "john.doe@example.com", $forbidden], + [false, true, "jane.doe@example.net", $forbidden], + [false, false, "jane.doe@example.com", $forbidden], + [false, false, "john.doe@example.com", $forbidden], + [false, false, "jane.doe@example.net", $forbidden], + [true, true, "jane.doe@example.com", true], + [true, true, "john.doe@example.com", true], + [true, true, "jane.doe@example.net", $missing], + [true, false, "jane.doe@example.com", true], + [true, false, "john.doe@example.com", true], + [true, false, "jane.doe@example.net", $missing], + ]; + } } diff --git a/tests/docroot/Import/OPML/BrokenOPML.1.opml b/tests/docroot/Import/OPML/BrokenOPML.1.opml new file mode 100644 index 0000000..a626ae0 --- /dev/null +++ b/tests/docroot/Import/OPML/BrokenOPML.1.opml @@ -0,0 +1,2 @@ + + diff --git a/tests/docroot/Import/OPML/BrokenOPML.2.opml b/tests/docroot/Import/OPML/BrokenOPML.2.opml new file mode 100644 index 0000000..ac70153 --- /dev/null +++ b/tests/docroot/Import/OPML/BrokenOPML.2.opml @@ -0,0 +1,2 @@ + + diff --git a/tests/docroot/Import/OPML/BrokenOPML.3.opml b/tests/docroot/Import/OPML/BrokenOPML.3.opml new file mode 100644 index 0000000..b087a1b --- /dev/null +++ b/tests/docroot/Import/OPML/BrokenOPML.3.opml @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/docroot/Import/OPML/BrokenOPML.4.opml b/tests/docroot/Import/OPML/BrokenOPML.4.opml new file mode 100644 index 0000000..544e4c3 --- /dev/null +++ b/tests/docroot/Import/OPML/BrokenOPML.4.opml @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/docroot/Import/OPML/BrokenXML.opml b/tests/docroot/Import/OPML/BrokenXML.opml new file mode 100644 index 0000000..0cbc6fe --- /dev/null +++ b/tests/docroot/Import/OPML/BrokenXML.opml @@ -0,0 +1,2 @@ + + diff --git a/tests/docroot/Import/OPML/Empty.1.opml b/tests/docroot/Import/OPML/Empty.1.opml new file mode 100644 index 0000000..4999faa --- /dev/null +++ b/tests/docroot/Import/OPML/Empty.1.opml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/docroot/Import/OPML/Empty.2.opml b/tests/docroot/Import/OPML/Empty.2.opml new file mode 100644 index 0000000..6dcd03f --- /dev/null +++ b/tests/docroot/Import/OPML/Empty.2.opml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/docroot/Import/OPML/Empty.3.opml b/tests/docroot/Import/OPML/Empty.3.opml new file mode 100644 index 0000000..59fd9b4 --- /dev/null +++ b/tests/docroot/Import/OPML/Empty.3.opml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/docroot/Import/OPML/FeedsOnly.opml b/tests/docroot/Import/OPML/FeedsOnly.opml new file mode 100644 index 0000000..4e68260 --- /dev/null +++ b/tests/docroot/Import/OPML/FeedsOnly.opml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/docroot/Import/OPML/FoldersOnly.opml b/tests/docroot/Import/OPML/FoldersOnly.opml new file mode 100644 index 0000000..34b7a69 --- /dev/null +++ b/tests/docroot/Import/OPML/FoldersOnly.opml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/docroot/Import/OPML/MixedContent.opml b/tests/docroot/Import/OPML/MixedContent.opml new file mode 100644 index 0000000..db65a2c --- /dev/null +++ b/tests/docroot/Import/OPML/MixedContent.opml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/tests/docroot/Import/some-feed.php b/tests/docroot/Import/some-feed.php new file mode 100644 index 0000000..eec5856 --- /dev/null +++ b/tests/docroot/Import/some-feed.php @@ -0,0 +1,18 @@ + "application/rss+xml", + 'content' => << + + Some feed + http://example.com/ + Just a generic feed + + + http://localhost:8000/Import/some-feed/some-article + Some article + This feed is used only to demonstrate failure modes external to the feed itself + + + +MESSAGE_BODY +]; diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 3814221..6334e5c 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -9,14 +9,16 @@ namespace JKingWeb\Arsse\Test; use JKingWeb\Arsse\Exception; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Conf; -use JKingWeb\Arsse\CLI; +use JKingWeb\Arsse\Db\Driver; +use JKingWeb\Arsse\Db\Result; use JKingWeb\Arsse\Misc\Date; +use JKingWeb\Arsse\Misc\ValueInfo; use Psr\Http\Message\MessageInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse; -use Zend\Diactoros\Response\EmptyResponse; +use Zend\Diactoros\Response\XmlResponse; /** @coversNothing */ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { @@ -42,30 +44,39 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { 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", - 'dbMySQLUser' => "arsse_test", - 'dbMySQLPass' => "arsse_test", - 'dbMySQLDb' => "arsse_test", + 'dbSQLite3File' => ":memory:", + 'dbSQLite3Timeout' => 0, + 'dbPostgreSQLHost' => $_ENV['ARSSE_TEST_PGSQL_HOST'] ?: "", + 'dbPostgreSQLPort' => $_ENV['ARSSE_TEST_PGSQL_PORT'] ?: 5432, + 'dbPostgreSQLUser' => $_ENV['ARSSE_TEST_PGSQL_USER'] ?: "arsse_test", + 'dbPostgreSQLPass' => $_ENV['ARSSE_TEST_PGSQL_PASS'] ?: "arsse_test", + 'dbPostgreSQLDb' => $_ENV['ARSSE_TEST_PGSQL_DB'] ?: "arsse_test", + 'dbPostgreSQLSchema' => $_ENV['ARSSE_TEST_PGSQL_SCHEMA'] ?: "arsse_test", + 'dbMySQLHost' => $_ENV['ARSSE_TEST_MYSQL_HOST'] ?: "localhost", + 'dbMySQLPort' => $_ENV['ARSSE_TEST_MYSQL_PORT'] ?: 3306, + 'dbMySQLUser' => $_ENV['ARSSE_TEST_MYSQL_USER'] ?: "arsse_test", + 'dbMySQLPass' => $_ENV['ARSSE_TEST_MYSQL_PASS'] ?: "arsse_test", + 'dbMySQLDb' => $_ENV['ARSSE_TEST_MYSQL_DB'] ?: "arsse_test", ]; Arsse::$conf = (($force ? null : Arsse::$conf) ?? (new Conf))->import($defaults)->import($conf); } - public function assertException(string $msg = "", string $prefix = "", string $type = "Exception") { + public function assertException($msg = "", string $prefix = "", string $type = "Exception") { if (func_num_args()) { - $class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type; - $msgID = ($prefix !== "" ? $prefix . "/" : "") . $type. ".$msg"; - if (array_key_exists($msgID, Exception::CODES)) { - $code = Exception::CODES[$msgID]; + if ($msg instanceof \JKingWeb\Arsse\AbstractException) { + $this->expectException(get_class($msg)); + $this->expectExceptionCode($msg->getCode()); } else { - $code = 0; + $class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type; + $msgID = ($prefix !== "" ? $prefix . "/" : "") . $type. ".$msg"; + if (array_key_exists($msgID, Exception::CODES)) { + $code = Exception::CODES[$msgID]; + } else { + $code = 0; + } + $this->expectException($class); + $this->expectExceptionCode($code); } - $this->expectException($class); - $this->expectExceptionCode($code); } else { // expecting a standard PHP exception $this->expectException(\Throwable::class); @@ -88,6 +99,8 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { if ($exp instanceof JsonResponse) { $this->assertEquals($exp->getPayload(), $act->getPayload(), $text); $this->assertSame($exp->getPayload(), $act->getPayload(), $text); + } elseif ($exp instanceof XmlResponse) { + $this->assertXmlStringEqualsXmlString((string) $exp->getBody(), (string) $act->getBody(), $text); } else { $this->assertEquals((string) $exp->getBody(), (string) $act->getBody(), $text); } @@ -130,4 +143,120 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { } return $value; } + + public function primeDatabase(Driver $drv, array $data): bool { + $tr = $drv->begin(); + foreach ($data as $table => $info) { + $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); + foreach ($info['rows'] as $row) { + $s->runArray($row); + } + } + $tr->commit(); + $this->primed = true; + return true; + } + + public function compareExpectations(Driver $drv, array $expected): bool { + foreach ($expected as $table => $info) { + $cols = array_map(function($v) { + return '"'.str_replace('"', '""', $v).'"'; + }, array_keys($info['columns'])); + $cols = implode(",", $cols); + $types = $info['columns']; + $data = $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 columns in array index $index of expectations for table $table does not match its definition"); + $row = array_combine($cols, $row); + foreach ($data as $index => $test) { + foreach ($test as $col => $value) { + switch ($types[$col]) { + case "datetime": + $test[$col] = $this->approximateTime($row[$col], $value); + break; + case "int": + $test[$col] = ValueInfo::normalize($value, ValueInfo::T_INT | ValueInfo::M_DROP | valueInfo::M_NULL); + break; + case "float": + $test[$col] = ValueInfo::normalize($value, ValueInfo::T_FLOAT | ValueInfo::M_DROP | valueInfo::M_NULL); + break; + case "bool": + $test[$col] = (int) ValueInfo::normalize($value, ValueInfo::T_BOOL | ValueInfo::M_DROP | valueInfo::M_NULL); + break; + } + } + if ($row===$test) { + $data[$index] = $test; + break; + } + } + $this->assertContains($row, $data, "Actual Table $table does not contain record at expected array index $index"); + $found = array_search($row, $data, true); + unset($data[$found]); + } + $this->assertSame([], $data, "Actual table $table contains extra rows not in expectations"); + } + return true; + } + + public function primeExpectations(array $source, array $tableSpecs): array { + $out = []; + foreach ($tableSpecs as $table => $columns) { + // make sure the source has the table we want + $this->assertArrayHasKey($table, $source, "Source for expectations does not contain requested table $table."); + $out[$table] = [ + 'columns' => [], + 'rows' => array_fill(0, sizeof($source[$table]['rows']), []), + ]; + // make sure the source has all the columns we want for the table + $cols = array_flip($columns); + $cols = array_intersect_key($cols, $source[$table]['columns']); + $this->assertSame(array_keys($cols), $columns, "Source for table $table does not contain all requested columns"); + // get a map of source value offsets and keys + $targets = array_flip(array_keys($source[$table]['columns'])); + foreach ($cols as $key => $order) { + // fill the column-spec + $out[$table]['columns'][$key] = $source[$table]['columns'][$key]; + foreach ($source[$table]['rows'] as $index => $row) { + // fill each row column-wise with re-ordered values + $out[$table]['rows'][$index][$order] = $row[$targets[$key]]; + } + } + } + return $out; + } + + public function assertResult(array $expected, Result $data) { + $data = $data->getAll(); + $this->assertCount(sizeof($expected), $data, "Number of result rows (".sizeof($data).") differs from number of expected rows (".sizeof($expected).")"); + if (sizeof($expected)) { + // make sure the expectations are consistent + foreach ($expected as $exp) { + if (!isset($keys)) { + $keys = $exp; + continue; + } + $this->assertSame(array_keys($keys), array_keys($exp), "Result set expectations are irregular"); + } + // filter the result set to contain just the desired keys (we don't care if the result has extra keys) + $rows = []; + foreach ($data as $row) { + $rows[] = array_intersect_key($row, $keys); + } + // compare the result set to the expectations + foreach ($rows as $row) { + $this->assertContains($row, $expected, "Result set contains unexpected record."); + $found = array_search($row, $expected); + unset($expected[$found]); + } + $this->assertArraySubset($expected, [], false, "Expectations not in result set."); + } + } } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 65a0893..5617ddb 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -7,7 +7,22 @@ convertWarningsToExceptions="false" beStrictAboutTestsThatDoNotTestAnything="true" beStrictAboutOutputDuringTests="true" - stopOnError="true"> + forceCoversAnnotation="true" +> + + + + + + + + + + + + + + @@ -99,13 +114,24 @@ cases/REST/NextCloudNews/PDO/TestV1_2.php + cases/REST/TinyTinyRSS/TestSearch.php cases/REST/TinyTinyRSS/TestAPI.php cases/REST/TinyTinyRSS/TestIcon.php cases/REST/TinyTinyRSS/PDO/TestAPI.php + + cases/REST/Fever/TestUser.php + cases/REST/Fever/TestAPI.php + cases/REST/Fever/PDO/TestAPI.php + cases/Service/TestService.php cases/CLI/TestCLI.php + + cases/ImportExport/TestFile.php + cases/ImportExport/TestImportExport.php + cases/ImportExport/TestOPML.php + diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock index ab76c71..faa225f 100644 --- a/vendor-bin/csfixer/composer.lock +++ b/vendor-bin/csfixer/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "composer/semver", - "version": "1.4.2", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "c7cb9a2095a074d131b65a8a0cd294479d785573" + "reference": "46d9139568ccb8d9e7cdd4539cab7347568a5e2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/c7cb9a2095a074d131b65a8a0cd294479d785573", - "reference": "c7cb9a2095a074d131b65a8a0cd294479d785573", + "url": "https://api.github.com/repos/composer/semver/zipball/46d9139568ccb8d9e7cdd4539cab7347568a5e2e", + "reference": "46d9139568ccb8d9e7cdd4539cab7347568a5e2e", "shasum": "" }, "require": { @@ -66,20 +66,20 @@ "validation", "versioning" ], - "time": "2016-08-30T16:08:34+00:00" + "time": "2019-03-19T17:25:45+00:00" }, { "name": "composer/xdebug-handler", - "version": "1.3.1", + "version": "1.3.3", "source": { "type": "git", "url": "https://github.com/composer/xdebug-handler.git", - "reference": "dc523135366eb68f22268d069ea7749486458562" + "reference": "46867cbf8ca9fb8d60c506895449eb799db1184f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/dc523135366eb68f22268d069ea7749486458562", - "reference": "dc523135366eb68f22268d069ea7749486458562", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/46867cbf8ca9fb8d60c506895449eb799db1184f", + "reference": "46867cbf8ca9fb8d60c506895449eb799db1184f", "shasum": "" }, "require": { @@ -110,20 +110,20 @@ "Xdebug", "performance" ], - "time": "2018-11-29T10:59:02+00:00" + "time": "2019-05-27T17:52:04+00:00" }, { "name": "doctrine/annotations", - "version": "v1.6.0", + "version": "v1.6.1", "source": { "type": "git", "url": "https://github.com/doctrine/annotations.git", - "reference": "c7f2050c68a9ab0bdb0f98567ec08d80ea7d24d5" + "reference": "53120e0eb10355388d6ccbe462f1fea34ddadb24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/annotations/zipball/c7f2050c68a9ab0bdb0f98567ec08d80ea7d24d5", - "reference": "c7f2050c68a9ab0bdb0f98567ec08d80ea7d24d5", + "url": "https://api.github.com/repos/doctrine/annotations/zipball/53120e0eb10355388d6ccbe462f1fea34ddadb24", + "reference": "53120e0eb10355388d6ccbe462f1fea34ddadb24", "shasum": "" }, "require": { @@ -178,25 +178,28 @@ "docblock", "parser" ], - "time": "2017-12-06T07:11:42+00:00" + "time": "2019-03-25T19:12:02+00:00" }, { "name": "doctrine/lexer", - "version": "v1.0.1", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/doctrine/lexer.git", - "reference": "83893c552fd2045dd78aef794c31e694c37c0b8c" + "reference": "1febd6c3ef84253d7c815bed85fc622ad207a9f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/83893c552fd2045dd78aef794c31e694c37c0b8c", - "reference": "83893c552fd2045dd78aef794c31e694c37c0b8c", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/1febd6c3ef84253d7c815bed85fc622ad207a9f8", + "reference": "1febd6c3ef84253d7c815bed85fc622ad207a9f8", "shasum": "" }, "require": { "php": ">=5.3.2" }, + "require-dev": { + "phpunit/phpunit": "^4.5" + }, "type": "library", "extra": { "branch-alias": { @@ -204,8 +207,8 @@ } }, "autoload": { - "psr-0": { - "Doctrine\\Common\\Lexer\\": "lib/" + "psr-4": { + "Doctrine\\Common\\Lexer\\": "lib/Doctrine/Common/Lexer" } }, "notification-url": "https://packagist.org/downloads/", @@ -226,26 +229,29 @@ "email": "schmittjoh@gmail.com" } ], - "description": "Base library for a lexer that can be used in Top-Down, Recursive Descent Parsers.", - "homepage": "http://www.doctrine-project.org", + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", "keywords": [ + "annotations", + "docblock", "lexer", - "parser" + "parser", + "php" ], - "time": "2014-09-09T13:34:57+00:00" + "time": "2019-06-08T11:03:04+00:00" }, { "name": "friendsofphp/php-cs-fixer", - "version": "v2.14.0", + "version": "v2.15.1", "source": { "type": "git", "url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git", - "reference": "b788ea0af899cedc8114dca7db119c93b6685da2" + "reference": "20064511ab796593a3990669eff5f5b535001f7c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/b788ea0af899cedc8114dca7db119c93b6685da2", - "reference": "b788ea0af899cedc8114dca7db119c93b6685da2", + "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/20064511ab796593a3990669eff5f5b535001f7c", + "reference": "20064511ab796593a3990669eff5f5b535001f7c", "shasum": "" }, "require": { @@ -266,9 +272,6 @@ "symfony/process": "^3.0 || ^4.0", "symfony/stopwatch": "^3.0 || ^4.0" }, - "conflict": { - "hhvm": "*" - }, "require-dev": { "johnkary/phpunit-speedtrap": "^1.1 || ^2.0 || ^3.0", "justinrainbow/json-schema": "^5.0", @@ -276,11 +279,11 @@ "mikey179/vfsstream": "^1.6", "php-coveralls/php-coveralls": "^2.1", "php-cs-fixer/accessible-object": "^1.0", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.0.1", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.0.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.1", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.1", "phpunit/phpunit": "^5.7.27 || ^6.5.8 || ^7.1", - "phpunitgoodpractices/traits": "^1.5.1", - "symfony/phpunit-bridge": "^4.0" + "phpunitgoodpractices/traits": "^1.8", + "symfony/phpunit-bridge": "^4.3" }, "suggest": { "ext-mbstring": "For handling non-UTF8 characters in cache signature.", @@ -292,11 +295,6 @@ "php-cs-fixer" ], "type": "application", - "extra": { - "branch-alias": { - "dev-master": "2.14-dev" - } - }, "autoload": { "psr-4": { "PhpCsFixer\\": "src/" @@ -328,7 +326,7 @@ } ], "description": "A tool to automatically fix PHP code style", - "time": "2019-01-04T18:29:47+00:00" + "time": "2019-06-01T10:32:12+00:00" }, { "name": "paragonie/random_compat", @@ -426,6 +424,55 @@ ], "time": "2018-02-15T16:58:55+00:00" }, + { + "name": "psr/container", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "time": "2017-02-14T16:28:37+00:00" + }, { "name": "psr/log", "version": "1.1.0", @@ -475,37 +522,43 @@ }, { "name": "symfony/console", - "version": "v4.2.2", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522" + "reference": "b592b26a24265a35172d8a2094d8b10f22b7cc39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", - "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", + "url": "https://api.github.com/repos/symfony/console/zipball/b592b26a24265a35172d8a2094d8b10f22b7cc39", + "reference": "b592b26a24265a35172d8a2094d8b10f22b7cc39", "shasum": "" }, "require": { "php": "^7.1.3", - "symfony/contracts": "^1.0", - "symfony/polyfill-mbstring": "~1.0" + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php73": "^1.8", + "symfony/service-contracts": "^1.1" }, "conflict": { "symfony/dependency-injection": "<3.4", + "symfony/event-dispatcher": "<4.3", "symfony/process": "<3.3" }, + "provide": { + "psr/log-implementation": "1.0" + }, "require-dev": { "psr/log": "~1.0", "symfony/config": "~3.4|~4.0", "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~3.4|~4.0", + "symfony/event-dispatcher": "^4.3", "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.4|~4.0" + "symfony/process": "~3.4|~4.0", + "symfony/var-dumper": "^4.3" }, "suggest": { - "psr/log-implementation": "For using the console logger", + "psr/log": "For using the console logger", "symfony/event-dispatcher": "", "symfony/lock": "", "symfony/process": "" @@ -513,7 +566,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -540,48 +593,58 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-01-04T15:13:53+00:00" + "time": "2019-06-13T11:03:18+00:00" }, { - "name": "symfony/contracts", - "version": "v1.0.2", + "name": "symfony/event-dispatcher", + "version": "v4.3.2", "source": { "type": "git", - "url": "https://github.com/symfony/contracts.git", - "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf" + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "d257021c1ab28d48d24a16de79dfab445ce93398" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/contracts/zipball/1aa7ab2429c3d594dd70689604b5cf7421254cdf", - "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d257021c1ab28d48d24a16de79dfab445ce93398", + "reference": "d257021c1ab28d48d24a16de79dfab445ce93398", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": "^7.1.3", + "symfony/event-dispatcher-contracts": "^1.1" + }, + "conflict": { + "symfony/dependency-injection": "<3.4" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "1.1" }, "require-dev": { - "psr/cache": "^1.0", - "psr/container": "^1.0" + "psr/log": "~1.0", + "symfony/config": "~3.4|~4.0", + "symfony/dependency-injection": "~3.4|~4.0", + "symfony/expression-language": "~3.4|~4.0", + "symfony/http-foundation": "^3.4|^4.0", + "symfony/service-contracts": "^1.1", + "symfony/stopwatch": "~3.4|~4.0" }, "suggest": { - "psr/cache": "When using the Cache contracts", - "psr/container": "When using the Service contracts", - "symfony/cache-contracts-implementation": "", - "symfony/service-contracts-implementation": "", - "symfony/translation-contracts-implementation": "" + "symfony/dependency-injection": "", + "symfony/http-kernel": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "4.3-dev" } }, "autoload": { "psr-4": { - "Symfony\\Contracts\\": "" + "Symfony\\Component\\EventDispatcher\\": "" }, "exclude-from-classmap": [ - "**/Tests/" + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -590,71 +653,49 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "A set of abstractions extracted out of the Symfony components", + "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "time": "2018-12-05T08:06:11+00:00" + "time": "2019-06-13T11:03:18+00:00" }, { - "name": "symfony/event-dispatcher", - "version": "v4.2.2", + "name": "symfony/event-dispatcher-contracts", + "version": "v1.1.5", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e" + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c61766f4440ca687de1084a5c00b08e167a2575c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/887de6d34c86cf0cb6cbf910afb170cdb743cb5e", - "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c61766f4440ca687de1084a5c00b08e167a2575c", + "reference": "c61766f4440ca687de1084a5c00b08e167a2575c", "shasum": "" }, "require": { - "php": "^7.1.3", - "symfony/contracts": "^1.0" - }, - "conflict": { - "symfony/dependency-injection": "<3.4" - }, - "require-dev": { - "psr/log": "~1.0", - "symfony/config": "~3.4|~4.0", - "symfony/dependency-injection": "~3.4|~4.0", - "symfony/expression-language": "~3.4|~4.0", - "symfony/stopwatch": "~3.4|~4.0" + "php": "^7.1.3" }, "suggest": { - "symfony/dependency-injection": "", - "symfony/http-kernel": "" + "psr/event-dispatcher": "", + "symfony/event-dispatcher-implementation": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "1.1-dev" } }, "autoload": { "psr-4": { - "Symfony\\Component\\EventDispatcher\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Contracts\\EventDispatcher\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -662,30 +703,38 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony EventDispatcher Component", + "description": "Generic abstractions related to dispatching event", "homepage": "https://symfony.com", - "time": "2019-01-05T16:37:49+00:00" + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-06-20T06:46:26+00:00" }, { "name": "symfony/filesystem", - "version": "v4.2.2", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8" + "reference": "b9896d034463ad6fd2bf17e2bf9418caecd6313d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", - "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/b9896d034463ad6fd2bf17e2bf9418caecd6313d", + "reference": "b9896d034463ad6fd2bf17e2bf9418caecd6313d", "shasum": "" }, "require": { @@ -695,7 +744,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -722,20 +771,20 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-06-23T08:51:25+00:00" }, { "name": "symfony/finder", - "version": "v4.2.2", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce" + "reference": "33c21f7d5d3dc8a140c282854a7e13aeb5d0f91a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", - "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", + "url": "https://api.github.com/repos/symfony/finder/zipball/33c21f7d5d3dc8a140c282854a7e13aeb5d0f91a", + "reference": "33c21f7d5d3dc8a140c282854a7e13aeb5d0f91a", "shasum": "" }, "require": { @@ -744,7 +793,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -771,20 +820,20 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-06-13T11:03:18+00:00" }, { "name": "symfony/options-resolver", - "version": "v4.2.2", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "fbcb106aeee72f3450298bf73324d2cc00d083d1" + "reference": "40762ead607c8f792ee4516881369ffa553fee6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/fbcb106aeee72f3450298bf73324d2cc00d083d1", - "reference": "fbcb106aeee72f3450298bf73324d2cc00d083d1", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/40762ead607c8f792ee4516881369ffa553fee6f", + "reference": "40762ead607c8f792ee4516881369ffa553fee6f", "shasum": "" }, "require": { @@ -793,7 +842,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -825,20 +874,20 @@ "configuration", "options" ], - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-06-13T11:01:17+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19" + "reference": "82ebae02209c21113908c229e9883c419720738a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e3d826245268269cd66f8326bd8bc066687b4a19", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/82ebae02209c21113908c229e9883c419720738a", + "reference": "82ebae02209c21113908c229e9883c419720738a", "shasum": "" }, "require": { @@ -850,7 +899,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -883,20 +932,20 @@ "polyfill", "portable" ], - "time": "2018-08-06T14:22:27+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "c79c051f5b3a46be09205c73b80b346e4153e494" + "reference": "fe5e94c604826c35a32fa832f35bd036b6799609" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/c79c051f5b3a46be09205c73b80b346e4153e494", - "reference": "c79c051f5b3a46be09205c73b80b346e4153e494", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fe5e94c604826c35a32fa832f35bd036b6799609", + "reference": "fe5e94c604826c35a32fa832f35bd036b6799609", "shasum": "" }, "require": { @@ -908,7 +957,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -942,20 +991,20 @@ "portable", "shim" ], - "time": "2018-09-21T13:07:52+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/polyfill-php70", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php70.git", - "reference": "6b88000cdd431cd2e940caa2cb569201f3f84224" + "reference": "bc4858fb611bda58719124ca079baff854149c89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/6b88000cdd431cd2e940caa2cb569201f3f84224", - "reference": "6b88000cdd431cd2e940caa2cb569201f3f84224", + "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/bc4858fb611bda58719124ca079baff854149c89", + "reference": "bc4858fb611bda58719124ca079baff854149c89", "shasum": "" }, "require": { @@ -965,7 +1014,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -1001,20 +1050,20 @@ "portable", "shim" ], - "time": "2018-09-21T06:26:08+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/polyfill-php72", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "9050816e2ca34a8e916c3a0ae8b9c2fccf68b631" + "reference": "ab50dcf166d5f577978419edd37aa2bb8eabce0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/9050816e2ca34a8e916c3a0ae8b9c2fccf68b631", - "reference": "9050816e2ca34a8e916c3a0ae8b9c2fccf68b631", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/ab50dcf166d5f577978419edd37aa2bb8eabce0c", + "reference": "ab50dcf166d5f577978419edd37aa2bb8eabce0c", "shasum": "" }, "require": { @@ -1023,7 +1072,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -1056,20 +1105,78 @@ "portable", "shim" ], - "time": "2018-09-21T13:07:52+00:00" + "time": "2019-02-06T07:57:58+00:00" + }, + { + "name": "symfony/polyfill-php73", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", + "reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.11-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/process", - "version": "v4.2.2", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "ea043ab5d8ed13b467a9087d81cb876aee7f689a" + "reference": "856d35814cf287480465bb7a6c413bb7f5f5e69c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/ea043ab5d8ed13b467a9087d81cb876aee7f689a", - "reference": "ea043ab5d8ed13b467a9087d81cb876aee7f689a", + "url": "https://api.github.com/repos/symfony/process/zipball/856d35814cf287480465bb7a6c413bb7f5f5e69c", + "reference": "856d35814cf287480465bb7a6c413bb7f5f5e69c", "shasum": "" }, "require": { @@ -1078,7 +1185,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1105,30 +1212,88 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-01-03T14:48:52+00:00" + "time": "2019-05-30T16:10:05+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v1.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", + "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "psr/container": "^1.0" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-06-13T11:15:36+00:00" }, { "name": "symfony/stopwatch", - "version": "v4.2.2", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "af62b35760fc92c8dbdce659b4eebdfe0e6a0472" + "reference": "6b100e9309e8979cf1978ac1778eb155c1f7d93b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/af62b35760fc92c8dbdce659b4eebdfe0e6a0472", - "reference": "af62b35760fc92c8dbdce659b4eebdfe0e6a0472", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/6b100e9309e8979cf1978ac1778eb155c1f7d93b", + "reference": "6b100e9309e8979cf1978ac1778eb155c1f7d93b", "shasum": "" }, "require": { "php": "^7.1.3", - "symfony/contracts": "^1.0" + "symfony/service-contracts": "^1.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1155,7 +1320,7 @@ ], "description": "Symfony Stopwatch Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-05-27T08:16:38+00:00" } ], "packages-dev": [], diff --git a/vendor-bin/phpunit/composer.json b/vendor-bin/phpunit/composer.json index 2fe20f7..7faefcb 100644 --- a/vendor-bin/phpunit/composer.json +++ b/vendor-bin/phpunit/composer.json @@ -1,9 +1,9 @@ { "require": { - "phpunit/phpunit": "^6.5", + "phpunit/phpunit": "6.* | 7.*", "phake/phake": "^3.0", "clue/arguments": "^2.0", - "mikey179/vfsStream": "^1.6", + "mikey179/vfsstream": "^1.6", "webmozart/glob": "^4.1" } } diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index fb99feb..e27b08c 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4252b3d7817c9a4a5f60ac81f28202e2", + "content-hash": "0efc271cb10b6582cac5f373a48fc969", "packages": [ { "name": "clue/arguments", @@ -58,27 +58,29 @@ }, { "name": "doctrine/instantiator", - "version": "1.1.0", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda" + "reference": "a2c590166b2133a4633738648b6b064edae0814a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda", - "reference": "185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/a2c590166b2133a4633738648b6b064edae0814a", + "reference": "a2c590166b2133a4633738648b6b064edae0814a", "shasum": "" }, "require": { "php": "^7.1" }, "require-dev": { - "athletic/athletic": "~0.1.8", + "doctrine/coding-standard": "^6.0", "ext-pdo": "*", "ext-phar": "*", - "phpunit/phpunit": "^6.2.3", - "squizlabs/php_codesniffer": "^3.0.2" + "phpbench/phpbench": "^0.13", + "phpstan/phpstan-phpunit": "^0.11", + "phpstan/phpstan-shim": "^0.11", + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { @@ -103,25 +105,25 @@ } ], "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://github.com/doctrine/instantiator", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", "keywords": [ "constructor", "instantiate" ], - "time": "2017-07-22T11:58:36+00:00" + "time": "2019-03-17T17:37:11+00:00" }, { - "name": "mikey179/vfsStream", - "version": "v1.6.5", + "name": "mikey179/vfsstream", + "version": "v1.6.6", "source": { "type": "git", - "url": "https://github.com/mikey179/vfsStream.git", - "reference": "d5fec95f541d4d71c4823bb5e30cf9b9e5b96145" + "url": "https://github.com/bovigo/vfsStream.git", + "reference": "095238a0711c974ae5b4ebf4c4534a23f3f6c99d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mikey179/vfsStream/zipball/d5fec95f541d4d71c4823bb5e30cf9b9e5b96145", - "reference": "d5fec95f541d4d71c4823bb5e30cf9b9e5b96145", + "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/095238a0711c974ae5b4ebf4c4534a23f3f6c99d", + "reference": "095238a0711c974ae5b4ebf4c4534a23f3f6c99d", "shasum": "" }, "require": { @@ -154,20 +156,20 @@ ], "description": "Virtual file system to mock the real file system in unit tests.", "homepage": "http://vfs.bovigo.org/", - "time": "2017-08-01T08:02:14+00:00" + "time": "2019-04-08T13:54:32+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.8.1", + "version": "1.9.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8" + "reference": "e6828efaba2c9b79f4499dae1d66ef8bfa7b2b72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8", - "reference": "3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/e6828efaba2c9b79f4499dae1d66ef8bfa7b2b72", + "reference": "e6828efaba2c9b79f4499dae1d66ef8bfa7b2b72", "shasum": "" }, "require": { @@ -202,20 +204,20 @@ "object", "object graph" ], - "time": "2018-06-11T23:09:50+00:00" + "time": "2019-04-07T13:18:21+00:00" }, { "name": "phake/phake", - "version": "v3.1.3", + "version": "v3.1.6", "source": { "type": "git", "url": "https://github.com/mlively/Phake.git", - "reference": "5208167c10f3c0b8e87066d6d5b41e6b754bd4d4" + "reference": "3848901ed8e236534ae684dd5cf0f3bfc4c8a24c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mlively/Phake/zipball/5208167c10f3c0b8e87066d6d5b41e6b754bd4d4", - "reference": "5208167c10f3c0b8e87066d6d5b41e6b754bd4d4", + "url": "https://api.github.com/repos/mlively/Phake/zipball/3848901ed8e236534ae684dd5cf0f3bfc4c8a24c", + "reference": "3848901ed8e236534ae684dd5cf0f3bfc4c8a24c", "shasum": "" }, "require": { @@ -260,26 +262,26 @@ "mock", "testing" ], - "time": "2018-08-04T00:42:49+00:00" + "time": "2019-06-06T22:41:35+00:00" }, { "name": "phar-io/manifest", - "version": "1.0.1", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", - "reference": "2df402786ab5368a0169091f61a7c1e0eb6852d0" + "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/2df402786ab5368a0169091f61a7c1e0eb6852d0", - "reference": "2df402786ab5368a0169091f61a7c1e0eb6852d0", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", + "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", "shasum": "" }, "require": { "ext-dom": "*", "ext-phar": "*", - "phar-io/version": "^1.0.1", + "phar-io/version": "^2.0", "php": "^5.6 || ^7.0" }, "type": "library", @@ -315,20 +317,20 @@ } ], "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", - "time": "2017-03-05T18:14:27+00:00" + "time": "2018-07-08T19:23:20+00:00" }, { "name": "phar-io/version", - "version": "1.0.1", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/phar-io/version.git", - "reference": "a70c0ced4be299a63d32fa96d9281d03e94041df" + "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/a70c0ced4be299a63d32fa96d9281d03e94041df", - "reference": "a70c0ced4be299a63d32fa96d9281d03e94041df", + "url": "https://api.github.com/repos/phar-io/version/zipball/45a2ec53a73c70ce41d55cedef9063630abaf1b6", + "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6", "shasum": "" }, "require": { @@ -362,7 +364,7 @@ } ], "description": "Library for handling version information and constraints", - "time": "2017-03-05T17:38:23+00:00" + "time": "2018-07-08T19:19:57+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -420,16 +422,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "4.3.0", + "version": "4.3.1", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "94fd0001232e47129dd3504189fa1c7225010d08" + "reference": "bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/94fd0001232e47129dd3504189fa1c7225010d08", - "reference": "94fd0001232e47129dd3504189fa1c7225010d08", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c", + "reference": "bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c", "shasum": "" }, "require": { @@ -467,7 +469,7 @@ } ], "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", - "time": "2017-11-30T07:14:17+00:00" + "time": "2019-04-30T17:48:53+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -518,16 +520,16 @@ }, { "name": "phpspec/prophecy", - "version": "1.8.0", + "version": "1.8.1", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "4ba436b55987b4bf311cb7c6ba82aa528aac0a06" + "reference": "1927e75f4ed19131ec9bcc3b002e07fb1173ee76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/4ba436b55987b4bf311cb7c6ba82aa528aac0a06", - "reference": "4ba436b55987b4bf311cb7c6ba82aa528aac0a06", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/1927e75f4ed19131ec9bcc3b002e07fb1173ee76", + "reference": "1927e75f4ed19131ec9bcc3b002e07fb1173ee76", "shasum": "" }, "require": { @@ -548,8 +550,8 @@ } }, "autoload": { - "psr-0": { - "Prophecy\\": "src/" + "psr-4": { + "Prophecy\\": "src/Prophecy" } }, "notification-url": "https://packagist.org/downloads/", @@ -577,44 +579,44 @@ "spy", "stub" ], - "time": "2018-08-05T17:53:17+00:00" + "time": "2019-06-13T12:50:23+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "5.3.2", + "version": "6.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "c89677919c5dd6d3b3852f230a663118762218ac" + "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/c89677919c5dd6d3b3852f230a663118762218ac", - "reference": "c89677919c5dd6d3b3852f230a663118762218ac", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", + "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", "shasum": "" }, "require": { "ext-dom": "*", "ext-xmlwriter": "*", - "php": "^7.0", - "phpunit/php-file-iterator": "^1.4.2", + "php": "^7.1", + "phpunit/php-file-iterator": "^2.0", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-token-stream": "^2.0.1", + "phpunit/php-token-stream": "^3.0", "sebastian/code-unit-reverse-lookup": "^1.0.1", - "sebastian/environment": "^3.0", + "sebastian/environment": "^3.1 || ^4.0", "sebastian/version": "^2.0.1", "theseer/tokenizer": "^1.1" }, "require-dev": { - "phpunit/phpunit": "^6.0" + "phpunit/phpunit": "^7.0" }, "suggest": { - "ext-xdebug": "^2.5.5" + "ext-xdebug": "^2.6.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.3.x-dev" + "dev-master": "6.1-dev" } }, "autoload": { @@ -640,29 +642,32 @@ "testing", "xunit" ], - "time": "2018-04-06T15:36:58+00:00" + "time": "2018-10-31T16:06:48+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "1.4.5", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4" + "reference": "050bedf145a257b1ff02746c31894800e5122946" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/730b01bc3e867237eaac355e06a36b85dd93a8b4", - "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/050bedf145a257b1ff02746c31894800e5122946", + "reference": "050bedf145a257b1ff02746c31894800e5122946", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": "^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.4.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { @@ -677,7 +682,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], @@ -687,7 +692,7 @@ "filesystem", "iterator" ], - "time": "2017-11-27T13:52:08+00:00" + "time": "2018-09-13T20:33:42+00:00" }, { "name": "phpunit/php-text-template", @@ -732,28 +737,28 @@ }, { "name": "phpunit/php-timer", - "version": "1.0.9", + "version": "2.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f" + "reference": "1038454804406b0b5f5f520358e78c1c2f71501e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", - "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/1038454804406b0b5f5f520358e78c1c2f71501e", + "reference": "1038454804406b0b5f5f520358e78c1c2f71501e", "shasum": "" }, "require": { - "php": "^5.3.3 || ^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "2.1-dev" } }, "autoload": { @@ -768,7 +773,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], @@ -777,33 +782,33 @@ "keywords": [ "timer" ], - "time": "2017-02-26T11:10:40+00:00" + "time": "2019-06-07T04:22:29+00:00" }, { "name": "phpunit/php-token-stream", - "version": "2.0.2", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "791198a2c6254db10131eecfe8c06670700904db" + "reference": "c4a66b97f040e3e20b3aa2a243230a1c3a9f7c8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/791198a2c6254db10131eecfe8c06670700904db", - "reference": "791198a2c6254db10131eecfe8c06670700904db", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/c4a66b97f040e3e20b3aa2a243230a1c3a9f7c8c", + "reference": "c4a66b97f040e3e20b3aa2a243230a1c3a9f7c8c", "shasum": "" }, "require": { "ext-tokenizer": "*", - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.2.4" + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -826,57 +831,57 @@ "keywords": [ "tokenizer" ], - "time": "2017-11-27T05:48:46+00:00" + "time": "2019-07-08T05:24:54+00:00" }, { "name": "phpunit/phpunit", - "version": "6.5.13", + "version": "7.5.14", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "0973426fb012359b2f18d3bd1e90ef1172839693" + "reference": "2834789aeb9ac182ad69bfdf9ae91856a59945ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0973426fb012359b2f18d3bd1e90ef1172839693", - "reference": "0973426fb012359b2f18d3bd1e90ef1172839693", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2834789aeb9ac182ad69bfdf9ae91856a59945ff", + "reference": "2834789aeb9ac182ad69bfdf9ae91856a59945ff", "shasum": "" }, "require": { + "doctrine/instantiator": "^1.1", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ext-xml": "*", - "myclabs/deep-copy": "^1.6.1", - "phar-io/manifest": "^1.0.1", - "phar-io/version": "^1.0", - "php": "^7.0", + "myclabs/deep-copy": "^1.7", + "phar-io/manifest": "^1.0.2", + "phar-io/version": "^2.0", + "php": "^7.1", "phpspec/prophecy": "^1.7", - "phpunit/php-code-coverage": "^5.3", - "phpunit/php-file-iterator": "^1.4.3", + "phpunit/php-code-coverage": "^6.0.7", + "phpunit/php-file-iterator": "^2.0.1", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-timer": "^1.0.9", - "phpunit/phpunit-mock-objects": "^5.0.9", - "sebastian/comparator": "^2.1", - "sebastian/diff": "^2.0", - "sebastian/environment": "^3.1", + "phpunit/php-timer": "^2.1", + "sebastian/comparator": "^3.0", + "sebastian/diff": "^3.0", + "sebastian/environment": "^4.0", "sebastian/exporter": "^3.1", "sebastian/global-state": "^2.0", "sebastian/object-enumerator": "^3.0.3", - "sebastian/resource-operations": "^1.0", + "sebastian/resource-operations": "^2.0", "sebastian/version": "^2.0.1" }, "conflict": { - "phpdocumentor/reflection-docblock": "3.0.2", - "phpunit/dbunit": "<3.0" + "phpunit/phpunit-mock-objects": "*" }, "require-dev": { "ext-pdo": "*" }, "suggest": { + "ext-soap": "*", "ext-xdebug": "*", - "phpunit/php-invoker": "^1.1" + "phpunit/php-invoker": "^2.0" }, "bin": [ "phpunit" @@ -884,7 +889,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "6.5.x-dev" + "dev-master": "7.5-dev" } }, "autoload": { @@ -899,8 +904,8 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "role": "lead", + "email": "sebastian@phpunit.de" } ], "description": "The PHP Unit Testing framework.", @@ -910,66 +915,7 @@ "testing", "xunit" ], - "time": "2018-09-08T15:10:43+00:00" - }, - { - "name": "phpunit/phpunit-mock-objects", - "version": "5.0.10", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", - "reference": "cd1cf05c553ecfec36b170070573e540b67d3f1f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/cd1cf05c553ecfec36b170070573e540b67d3f1f", - "reference": "cd1cf05c553ecfec36b170070573e540b67d3f1f", - "shasum": "" - }, - "require": { - "doctrine/instantiator": "^1.0.5", - "php": "^7.0", - "phpunit/php-text-template": "^1.2.1", - "sebastian/exporter": "^3.1" - }, - "conflict": { - "phpunit/phpunit": "<6.0" - }, - "require-dev": { - "phpunit/phpunit": "^6.5.11" - }, - "suggest": { - "ext-soap": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Mock Object library for PHPUnit", - "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/", - "keywords": [ - "mock", - "xunit" - ], - "time": "2018-08-09T05:50:03+00:00" + "time": "2019-07-15T06:24:08+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -1018,30 +964,30 @@ }, { "name": "sebastian/comparator", - "version": "2.1.3", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "34369daee48eafb2651bea869b4b15d75ccc35f9" + "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/34369daee48eafb2651bea869b4b15d75ccc35f9", - "reference": "34369daee48eafb2651bea869b4b15d75ccc35f9", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/5de4fc177adf9bce8df98d8d141a7559d7ccf6da", + "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da", "shasum": "" }, "require": { - "php": "^7.0", - "sebastian/diff": "^2.0 || ^3.0", + "php": "^7.1", + "sebastian/diff": "^3.0", "sebastian/exporter": "^3.1" }, "require-dev": { - "phpunit/phpunit": "^6.4" + "phpunit/phpunit": "^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1.x-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -1078,32 +1024,33 @@ "compare", "equality" ], - "time": "2018-02-01T13:46:46+00:00" + "time": "2018-07-12T15:12:46+00:00" }, { "name": "sebastian/diff", - "version": "2.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd" + "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/347c1d8b49c5c3ee30c7040ea6fc446790e6bddd", - "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/720fcc7e9b5cf384ea68d9d930d480907a0c1a29", + "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29", "shasum": "" }, "require": { - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.2" + "phpunit/phpunit": "^7.5 || ^8.0", + "symfony/process": "^2 || ^3.3 || ^4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -1128,34 +1075,40 @@ "description": "Diff implementation", "homepage": "https://github.com/sebastianbergmann/diff", "keywords": [ - "diff" + "diff", + "udiff", + "unidiff", + "unified diff" ], - "time": "2017-08-03T08:09:46+00:00" + "time": "2019-02-04T06:01:07+00:00" }, { "name": "sebastian/environment", - "version": "3.1.0", + "version": "4.2.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5" + "reference": "f2a2c8e1c97c11ace607a7a667d73d47c19fe404" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/cd0871b3975fb7fc44d11314fd1ee20925fce4f5", - "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/f2a2c8e1c97c11ace607a7a667d73d47c19fe404", + "reference": "f2a2c8e1c97c11ace607a7a667d73d47c19fe404", "shasum": "" }, "require": { - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.1" + "phpunit/phpunit": "^7.5" + }, + "suggest": { + "ext-posix": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1.x-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1180,7 +1133,7 @@ "environment", "hhvm" ], - "time": "2017-07-01T08:51:00+00:00" + "time": "2019-05-05T09:05:15+00:00" }, { "name": "sebastian/exporter", @@ -1447,25 +1400,25 @@ }, { "name": "sebastian/resource-operations", - "version": "1.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52" + "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", - "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/4d7a795d35b889bf80a0cc04e08d77cedfa917a9", + "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9", "shasum": "" }, "require": { - "php": ">=5.6.0" + "php": "^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -1485,7 +1438,7 @@ ], "description": "Provides a list of PHP built-in functions that operate on resources", "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "time": "2015-07-28T20:34:47+00:00" + "time": "2018-10-04T04:07:39+00:00" }, { "name": "sebastian/version", @@ -1532,16 +1485,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19" + "reference": "82ebae02209c21113908c229e9883c419720738a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e3d826245268269cd66f8326bd8bc066687b4a19", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/82ebae02209c21113908c229e9883c419720738a", + "reference": "82ebae02209c21113908c229e9883c419720738a", "shasum": "" }, "require": { @@ -1553,7 +1506,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -1586,20 +1539,20 @@ "polyfill", "portable" ], - "time": "2018-08-06T14:22:27+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "theseer/tokenizer", - "version": "1.1.0", + "version": "1.1.3", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "cb2f008f3f05af2893a87208fe6a6c4985483f8b" + "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/cb2f008f3f05af2893a87208fe6a6c4985483f8b", - "reference": "cb2f008f3f05af2893a87208fe6a6c4985483f8b", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/11336f6f84e16a720dae9d8e6ed5019efa85a0f9", + "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9", "shasum": "" }, "require": { @@ -1626,7 +1579,7 @@ } ], "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", - "time": "2017-04-07T12:08:54+00:00" + "time": "2019-06-13T22:48:21+00:00" }, { "name": "webmozart/assert", diff --git a/vendor-bin/robo/composer.lock b/vendor-bin/robo/composer.lock index 52ab736..a544acc 100644 --- a/vendor-bin/robo/composer.lock +++ b/vendor-bin/robo/composer.lock @@ -8,21 +8,21 @@ "packages": [ { "name": "consolidation/annotated-command", - "version": "2.11.0", + "version": "2.12.0", "source": { "type": "git", "url": "https://github.com/consolidation/annotated-command.git", - "reference": "edea407f57104ed518cc3c3b47d5b84403ee267a" + "reference": "512a2e54c98f3af377589de76c43b24652bcb789" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/edea407f57104ed518cc3c3b47d5b84403ee267a", - "reference": "edea407f57104ed518cc3c3b47d5b84403ee267a", + "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/512a2e54c98f3af377589de76c43b24652bcb789", + "reference": "512a2e54c98f3af377589de76c43b24652bcb789", "shasum": "" }, "require": { "consolidation/output-formatters": "^3.4", - "php": ">=5.4.0", + "php": ">=5.4.5", "psr/log": "^1", "symfony/console": "^2.8|^3|^4", "symfony/event-dispatcher": "^2.5|^3|^4", @@ -100,20 +100,20 @@ } ], "description": "Initialize Symfony Console commands from annotated command class methods.", - "time": "2018-12-29T04:43:17+00:00" + "time": "2019-03-08T16:55:03+00:00" }, { "name": "consolidation/config", - "version": "1.1.1", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/consolidation/config.git", - "reference": "925231dfff32f05b787e1fddb265e789b939cf4c" + "reference": "cac1279bae7efb5c7fb2ca4c3ba4b8eb741a96c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/config/zipball/925231dfff32f05b787e1fddb265e789b939cf4c", - "reference": "925231dfff32f05b787e1fddb265e789b939cf4c", + "url": "https://api.github.com/repos/consolidation/config/zipball/cac1279bae7efb5c7fb2ca4c3ba4b8eb741a96c1", + "reference": "cac1279bae7efb5c7fb2ca4c3ba4b8eb741a96c1", "shasum": "" }, "require": { @@ -122,9 +122,9 @@ "php": ">=5.4.0" }, "require-dev": { - "g1a/composer-test-scenarios": "^1", + "g1a/composer-test-scenarios": "^3", + "php-coveralls/php-coveralls": "^1", "phpunit/phpunit": "^5", - "satooshi/php-coveralls": "^1.0", "squizlabs/php_codesniffer": "2.*", "symfony/console": "^2.5|^3|^4", "symfony/yaml": "^2.8.11|^3|^4" @@ -134,6 +134,33 @@ }, "type": "library", "extra": { + "scenarios": { + "symfony4": { + "require-dev": { + "symfony/console": "^4.0" + }, + "config": { + "platform": { + "php": "7.1.3" + } + } + }, + "symfony2": { + "require-dev": { + "symfony/console": "^2.8", + "symfony/event-dispatcher": "^2.8", + "phpunit/phpunit": "^4.8.36" + }, + "remove": [ + "php-coveralls/php-coveralls" + ], + "config": { + "platform": { + "php": "5.4.8" + } + } + } + }, "branch-alias": { "dev-master": "1.x-dev" } @@ -154,7 +181,7 @@ } ], "description": "Provide configuration services for a commandline tool.", - "time": "2018-10-24T17:55:35+00:00" + "time": "2019-03-03T19:37:04+00:00" }, { "name": "consolidation/log", @@ -248,16 +275,16 @@ }, { "name": "consolidation/output-formatters", - "version": "3.4.0", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/consolidation/output-formatters.git", - "reference": "a942680232094c4a5b21c0b7e54c20cce623ae19" + "reference": "99ec998ffb697e0eada5aacf81feebfb13023605" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/output-formatters/zipball/a942680232094c4a5b21c0b7e54c20cce623ae19", - "reference": "a942680232094c4a5b21c0b7e54c20cce623ae19", + "url": "https://api.github.com/repos/consolidation/output-formatters/zipball/99ec998ffb697e0eada5aacf81feebfb13023605", + "reference": "99ec998ffb697e0eada5aacf81feebfb13023605", "shasum": "" }, "require": { @@ -267,11 +294,10 @@ "symfony/finder": "^2.5|^3|^4" }, "require-dev": { - "g1a/composer-test-scenarios": "^2", + "g1a/composer-test-scenarios": "^3", + "php-coveralls/php-coveralls": "^1", "phpunit/phpunit": "^5.7.27", - "satooshi/php-coveralls": "^2", "squizlabs/php_codesniffer": "^2.7", - "symfony/console": "3.2.3", "symfony/var-dumper": "^2.8|^3|^4", "victorjonsson/markdowndocs": "^1.3" }, @@ -280,6 +306,52 @@ }, "type": "library", "extra": { + "scenarios": { + "symfony4": { + "require": { + "symfony/console": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^6" + }, + "config": { + "platform": { + "php": "7.1.3" + } + } + }, + "symfony3": { + "require": { + "symfony/console": "^3.4", + "symfony/finder": "^3.4", + "symfony/var-dumper": "^3.4" + }, + "config": { + "platform": { + "php": "5.6.32" + } + } + }, + "symfony2": { + "require": { + "symfony/console": "^2.8" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.36" + }, + "remove": [ + "php-coveralls/php-coveralls" + ], + "config": { + "platform": { + "php": "5.4.8" + } + }, + "scenario-options": { + "create-lockfile": "false" + } + } + }, "branch-alias": { "dev-master": "3.x-dev" } @@ -300,25 +372,25 @@ } ], "description": "Format text by applying transformations provided by plug-in formatters.", - "time": "2018-10-19T22:35:38+00:00" + "time": "2019-05-30T23:16:01+00:00" }, { "name": "consolidation/robo", - "version": "1.4.3", + "version": "1.4.9", "source": { "type": "git", "url": "https://github.com/consolidation/Robo.git", - "reference": "d0b6f516ec940add7abed4f1432d30cca5f8ae0c" + "reference": "5c6b3840a45afda1cbffbb3bb1f94dd5f9f83345" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/Robo/zipball/d0b6f516ec940add7abed4f1432d30cca5f8ae0c", - "reference": "d0b6f516ec940add7abed4f1432d30cca5f8ae0c", + "url": "https://api.github.com/repos/consolidation/Robo/zipball/5c6b3840a45afda1cbffbb3bb1f94dd5f9f83345", + "reference": "5c6b3840a45afda1cbffbb3bb1f94dd5f9f83345", "shasum": "" }, "require": { "consolidation/annotated-command": "^2.10.2", - "consolidation/config": "^1.0.10", + "consolidation/config": "^1.2", "consolidation/log": "~1", "consolidation/output-formatters": "^3.1.13", "consolidation/self-update": "^1", @@ -344,7 +416,7 @@ "natxet/cssmin": "3.0.4", "nikic/php-parser": "^3.1.5", "patchwork/jsqueeze": "~2", - "pear/archive_tar": "^1.4.2", + "pear/archive_tar": "^1.4.4", "php-coveralls/php-coveralls": "^1", "phpunit/php-code-coverage": "~2|~4", "squizlabs/php_codesniffer": "^2.8" @@ -408,7 +480,7 @@ } ], "description": "Modern task runner", - "time": "2019-01-02T21:33:28+00:00" + "time": "2019-03-19T18:07:19+00:00" }, { "name": "consolidation/self-update", @@ -712,16 +784,16 @@ }, { "name": "pear/archive_tar", - "version": "1.4.5", + "version": "1.4.7", "source": { "type": "git", "url": "https://github.com/pear/Archive_Tar.git", - "reference": "ff716ca697c5e9e8593212cb785ffd03ee11b01f" + "reference": "7e48add6f8edc3027dd98ad15964b1a28fd0c845" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/ff716ca697c5e9e8593212cb785ffd03ee11b01f", - "reference": "ff716ca697c5e9e8593212cb785ffd03ee11b01f", + "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/7e48add6f8edc3027dd98ad15964b1a28fd0c845", + "reference": "7e48add6f8edc3027dd98ad15964b1a28fd0c845", "shasum": "" }, "require": { @@ -774,20 +846,20 @@ "archive", "tar" ], - "time": "2019-01-02T21:45:13+00:00" + "time": "2019-04-08T13:15:55+00:00" }, { "name": "pear/console_getopt", - "version": "v1.4.1", + "version": "v1.4.2", "source": { "type": "git", "url": "https://github.com/pear/Console_Getopt.git", - "reference": "82f05cd1aa3edf34e19aa7c8ca312ce13a6a577f" + "reference": "6c77aeb625b32bd752e89ee17972d103588b90c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pear/Console_Getopt/zipball/82f05cd1aa3edf34e19aa7c8ca312ce13a6a577f", - "reference": "82f05cd1aa3edf34e19aa7c8ca312ce13a6a577f", + "url": "https://api.github.com/repos/pear/Console_Getopt/zipball/6c77aeb625b32bd752e89ee17972d103588b90c0", + "reference": "6c77aeb625b32bd752e89ee17972d103588b90c0", "shasum": "" }, "type": "library", @@ -821,20 +893,20 @@ } ], "description": "More info available on: http://pear.php.net/package/Console_Getopt", - "time": "2015-07-20T20:28:12+00:00" + "time": "2019-02-06T16:52:33+00:00" }, { "name": "pear/pear-core-minimal", - "version": "v1.10.7", + "version": "v1.10.9", "source": { "type": "git", "url": "https://github.com/pear/pear-core-minimal.git", - "reference": "19a3e0fcd50492c4357372f623f55f1b144346da" + "reference": "742be8dd68c746a01e4b0a422258e9c9cae1c37f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pear/pear-core-minimal/zipball/19a3e0fcd50492c4357372f623f55f1b144346da", - "reference": "19a3e0fcd50492c4357372f623f55f1b144346da", + "url": "https://api.github.com/repos/pear/pear-core-minimal/zipball/742be8dd68c746a01e4b0a422258e9c9cae1c37f", + "reference": "742be8dd68c746a01e4b0a422258e9c9cae1c37f", "shasum": "" }, "require": { @@ -865,7 +937,7 @@ } ], "description": "Minimal set of PEAR core files to be used as composer dependency", - "time": "2018-12-05T20:03:52+00:00" + "time": "2019-03-13T18:15:44+00:00" }, { "name": "pear/pear_exception", @@ -1020,37 +1092,43 @@ }, { "name": "symfony/console", - "version": "v4.2.2", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522" + "reference": "b592b26a24265a35172d8a2094d8b10f22b7cc39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", - "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", + "url": "https://api.github.com/repos/symfony/console/zipball/b592b26a24265a35172d8a2094d8b10f22b7cc39", + "reference": "b592b26a24265a35172d8a2094d8b10f22b7cc39", "shasum": "" }, "require": { "php": "^7.1.3", - "symfony/contracts": "^1.0", - "symfony/polyfill-mbstring": "~1.0" + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php73": "^1.8", + "symfony/service-contracts": "^1.1" }, "conflict": { "symfony/dependency-injection": "<3.4", + "symfony/event-dispatcher": "<4.3", "symfony/process": "<3.3" }, + "provide": { + "psr/log-implementation": "1.0" + }, "require-dev": { "psr/log": "~1.0", "symfony/config": "~3.4|~4.0", "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~3.4|~4.0", + "symfony/event-dispatcher": "^4.3", "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.4|~4.0" + "symfony/process": "~3.4|~4.0", + "symfony/var-dumper": "^4.3" }, "suggest": { - "psr/log-implementation": "For using the console logger", + "psr/log": "For using the console logger", "symfony/event-dispatcher": "", "symfony/lock": "", "symfony/process": "" @@ -1058,7 +1136,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1085,48 +1163,58 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-01-04T15:13:53+00:00" + "time": "2019-06-13T11:03:18+00:00" }, { - "name": "symfony/contracts", - "version": "v1.0.2", + "name": "symfony/event-dispatcher", + "version": "v4.3.2", "source": { "type": "git", - "url": "https://github.com/symfony/contracts.git", - "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf" + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "d257021c1ab28d48d24a16de79dfab445ce93398" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/contracts/zipball/1aa7ab2429c3d594dd70689604b5cf7421254cdf", - "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d257021c1ab28d48d24a16de79dfab445ce93398", + "reference": "d257021c1ab28d48d24a16de79dfab445ce93398", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": "^7.1.3", + "symfony/event-dispatcher-contracts": "^1.1" + }, + "conflict": { + "symfony/dependency-injection": "<3.4" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "1.1" }, "require-dev": { - "psr/cache": "^1.0", - "psr/container": "^1.0" + "psr/log": "~1.0", + "symfony/config": "~3.4|~4.0", + "symfony/dependency-injection": "~3.4|~4.0", + "symfony/expression-language": "~3.4|~4.0", + "symfony/http-foundation": "^3.4|^4.0", + "symfony/service-contracts": "^1.1", + "symfony/stopwatch": "~3.4|~4.0" }, "suggest": { - "psr/cache": "When using the Cache contracts", - "psr/container": "When using the Service contracts", - "symfony/cache-contracts-implementation": "", - "symfony/service-contracts-implementation": "", - "symfony/translation-contracts-implementation": "" + "symfony/dependency-injection": "", + "symfony/http-kernel": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "4.3-dev" } }, "autoload": { "psr-4": { - "Symfony\\Contracts\\": "" + "Symfony\\Component\\EventDispatcher\\": "" }, "exclude-from-classmap": [ - "**/Tests/" + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1135,71 +1223,49 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "A set of abstractions extracted out of the Symfony components", + "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "time": "2018-12-05T08:06:11+00:00" + "time": "2019-06-13T11:03:18+00:00" }, { - "name": "symfony/event-dispatcher", - "version": "v4.2.2", + "name": "symfony/event-dispatcher-contracts", + "version": "v1.1.5", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e" + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c61766f4440ca687de1084a5c00b08e167a2575c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/887de6d34c86cf0cb6cbf910afb170cdb743cb5e", - "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c61766f4440ca687de1084a5c00b08e167a2575c", + "reference": "c61766f4440ca687de1084a5c00b08e167a2575c", "shasum": "" }, "require": { - "php": "^7.1.3", - "symfony/contracts": "^1.0" - }, - "conflict": { - "symfony/dependency-injection": "<3.4" - }, - "require-dev": { - "psr/log": "~1.0", - "symfony/config": "~3.4|~4.0", - "symfony/dependency-injection": "~3.4|~4.0", - "symfony/expression-language": "~3.4|~4.0", - "symfony/stopwatch": "~3.4|~4.0" + "php": "^7.1.3" }, "suggest": { - "symfony/dependency-injection": "", - "symfony/http-kernel": "" + "psr/event-dispatcher": "", + "symfony/event-dispatcher-implementation": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "1.1-dev" } }, "autoload": { "psr-4": { - "Symfony\\Component\\EventDispatcher\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Contracts\\EventDispatcher\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1207,30 +1273,38 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony EventDispatcher Component", + "description": "Generic abstractions related to dispatching event", "homepage": "https://symfony.com", - "time": "2019-01-05T16:37:49+00:00" + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-06-20T06:46:26+00:00" }, { "name": "symfony/filesystem", - "version": "v4.2.2", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8" + "reference": "b9896d034463ad6fd2bf17e2bf9418caecd6313d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", - "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/b9896d034463ad6fd2bf17e2bf9418caecd6313d", + "reference": "b9896d034463ad6fd2bf17e2bf9418caecd6313d", "shasum": "" }, "require": { @@ -1240,7 +1314,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1267,20 +1341,20 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-06-23T08:51:25+00:00" }, { "name": "symfony/finder", - "version": "v4.2.2", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce" + "reference": "33c21f7d5d3dc8a140c282854a7e13aeb5d0f91a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", - "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", + "url": "https://api.github.com/repos/symfony/finder/zipball/33c21f7d5d3dc8a140c282854a7e13aeb5d0f91a", + "reference": "33c21f7d5d3dc8a140c282854a7e13aeb5d0f91a", "shasum": "" }, "require": { @@ -1289,7 +1363,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1316,20 +1390,20 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-06-13T11:03:18+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19" + "reference": "82ebae02209c21113908c229e9883c419720738a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e3d826245268269cd66f8326bd8bc066687b4a19", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/82ebae02209c21113908c229e9883c419720738a", + "reference": "82ebae02209c21113908c229e9883c419720738a", "shasum": "" }, "require": { @@ -1341,7 +1415,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -1374,20 +1448,20 @@ "polyfill", "portable" ], - "time": "2018-08-06T14:22:27+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "c79c051f5b3a46be09205c73b80b346e4153e494" + "reference": "fe5e94c604826c35a32fa832f35bd036b6799609" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/c79c051f5b3a46be09205c73b80b346e4153e494", - "reference": "c79c051f5b3a46be09205c73b80b346e4153e494", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fe5e94c604826c35a32fa832f35bd036b6799609", + "reference": "fe5e94c604826c35a32fa832f35bd036b6799609", "shasum": "" }, "require": { @@ -1399,7 +1473,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -1433,20 +1507,78 @@ "portable", "shim" ], - "time": "2018-09-21T13:07:52+00:00" + "time": "2019-02-06T07:57:58+00:00" + }, + { + "name": "symfony/polyfill-php73", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", + "reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.11-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/process", - "version": "v3.4.21", + "version": "v3.4.29", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c" + "reference": "d129c017e8602507688ef2c3007951a16c1a8407" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c", - "reference": "0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c", + "url": "https://api.github.com/repos/symfony/process/zipball/d129c017e8602507688ef2c3007951a16c1a8407", + "reference": "d129c017e8602507688ef2c3007951a16c1a8407", "shasum": "" }, "require": { @@ -1482,20 +1614,78 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-01-02T21:24:08+00:00" + "time": "2019-05-30T15:47:52+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v1.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", + "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "psr/container": "^1.0" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-06-13T11:15:36+00:00" }, { "name": "symfony/yaml", - "version": "v4.2.2", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "d0aa6c0ea484087927b49fd513383a7d36190ca6" + "reference": "c60ecf5ba842324433b46f58dc7afc4487dbab99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/d0aa6c0ea484087927b49fd513383a7d36190ca6", - "reference": "d0aa6c0ea484087927b49fd513383a7d36190ca6", + "url": "https://api.github.com/repos/symfony/yaml/zipball/c60ecf5ba842324433b46f58dc7afc4487dbab99", + "reference": "c60ecf5ba842324433b46f58dc7afc4487dbab99", "shasum": "" }, "require": { @@ -1514,7 +1704,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1541,7 +1731,7 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-04-06T14:04:46+00:00" } ], "packages-dev": [],