Browse Source

Merge branch 'master' into manual

microsub
J. King 5 years ago
parent
commit
d6220c1bbb
  1. 41
      CHANGELOG
  2. 46
      README.md
  3. 9
      RoboFile.php
  4. 11
      UPGRADING
  5. 2
      arsse.php
  6. 12
      composer.json
  7. 207
      composer.lock
  8. 4
      dist/arsse.service
  9. 4
      dist/nginx-fcgi.conf
  10. 5
      dist/nginx.conf
  11. 11
      lib/AbstractException.php
  12. 2
      lib/Arsse.php
  13. 188
      lib/CLI.php
  14. 2
      lib/Conf.php
  15. 56
      lib/Context/Context.php
  16. 200
      lib/Context/ExclusionContext.php
  17. 1784
      lib/Database.php
  18. 62
      lib/Db/AbstractStatement.php
  19. 73
      lib/Db/Driver.php
  20. 10
      lib/Db/ExceptionRetry.php
  21. 19
      lib/Db/MySQL/Driver.php
  22. 18
      lib/Db/MySQL/Statement.php
  23. 4
      lib/Db/PDODriver.php
  24. 14
      lib/Db/PDOStatement.php
  25. 12
      lib/Db/PostgreSQL/Driver.php
  26. 16
      lib/Db/PostgreSQL/Statement.php
  27. 11
      lib/Db/SQLite3/AbstractPDODriver.php
  28. 15
      lib/Db/SQLite3/Driver.php
  29. 4
      lib/Db/SQLite3/ExceptionBuilder.php
  30. 40
      lib/Db/SQLite3/PDODriver.php
  31. 19
      lib/Db/SQLite3/PDOStatement.php
  32. 14
      lib/Db/SQLite3/Statement.php
  33. 58
      lib/Db/Statement.php
  34. 167
      lib/ImportExport/AbstractImportExport.php
  35. 10
      lib/ImportExport/Exception.php
  36. 155
      lib/ImportExport/OPML.php
  37. 94
      lib/Misc/Query.php
  38. 16
      lib/REST.php
  39. 415
      lib/REST/Fever/API.php
  40. 34
      lib/REST/Fever/User.php
  41. 12
      lib/REST/NextCloudNews/V1_2.php
  42. 34
      lib/REST/TinyTinyRSS/API.php
  43. 367
      lib/REST/TinyTinyRSS/Search.php
  44. 9
      lib/Service.php
  45. 19
      lib/User.php
  46. 2
      lib/User/Driver.php
  47. 15
      lib/User/Internal/Driver.php
  48. 12
      locale/en.php
  49. 11
      robo
  50. 10
      sql/MySQL/0.sql
  51. 4
      sql/MySQL/1.sql
  52. 2
      sql/MySQL/2.sql
  53. 2
      sql/MySQL/3.sql
  54. 41
      sql/MySQL/4.sql
  55. 2
      sql/PostgreSQL/0.sql
  56. 2
      sql/PostgreSQL/1.sql
  57. 2
      sql/PostgreSQL/2.sql
  58. 2
      sql/PostgreSQL/3.sql
  59. 40
      sql/PostgreSQL/4.sql
  60. 5
      sql/SQLite3/0.sql
  61. 8
      sql/SQLite3/1.sql
  62. 2
      sql/SQLite3/2.sql
  63. 2
      sql/SQLite3/3.sql
  64. 78
      sql/SQLite3/4.sql
  65. 1
      tests/bootstrap.php
  66. 179
      tests/cases/CLI/TestCLI.php
  67. 141
      tests/cases/Database/Base.php
  68. 390
      tests/cases/Database/SeriesArticle.php
  69. 44
      tests/cases/Database/SeriesCleanup.php
  70. 13
      tests/cases/Database/SeriesFeed.php
  71. 80
      tests/cases/Database/SeriesFolder.php
  72. 66
      tests/cases/Database/SeriesLabel.php
  73. 10
      tests/cases/Database/SeriesMeta.php
  74. 4
      tests/cases/Database/SeriesMiscellany.php
  75. 21
      tests/cases/Database/SeriesSession.php
  76. 90
      tests/cases/Database/SeriesSubscription.php
  77. 425
      tests/cases/Database/SeriesTag.php
  78. 140
      tests/cases/Database/SeriesToken.php
  79. 25
      tests/cases/Database/SeriesUser.php
  80. 2
      tests/cases/DatabaseDrivers/MySQL.php
  81. 2
      tests/cases/DatabaseDrivers/SQLite3.php
  82. 10
      tests/cases/Db/BaseDriver.php
  83. 14
      tests/cases/Db/BaseStatement.php
  84. 5
      tests/cases/Db/BaseUpdate.php
  85. 131
      tests/cases/ImportExport/TestFile.php
  86. 264
      tests/cases/ImportExport/TestImportExport.php
  87. 165
      tests/cases/ImportExport/TestOPML.php
  88. 63
      tests/cases/Misc/TestContext.php
  89. 13
      tests/cases/REST/Fever/PDO/TestAPI.php
  90. 514
      tests/cases/REST/Fever/TestAPI.php
  91. 94
      tests/cases/REST/Fever/TestUser.php
  92. 36
      tests/cases/REST/NextCloudNews/TestV1_2.php
  93. 101
      tests/cases/REST/TinyTinyRSS/TestAPI.php
  94. 125
      tests/cases/REST/TinyTinyRSS/TestSearch.php
  95. 52
      tests/cases/User/TestInternal.php
  96. 38
      tests/cases/User/TestUser.php
  97. 2
      tests/docroot/Import/OPML/BrokenOPML.1.opml
  98. 2
      tests/docroot/Import/OPML/BrokenOPML.2.opml
  99. 6
      tests/docroot/Import/OPML/BrokenOPML.3.opml
  100. 5
      tests/docroot/Import/OPML/BrokenOPML.4.opml

41
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)
==========================

46
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 <username> [<password>]` 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

9
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();
}

11
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
=============================

2
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);
}

12
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": "*"

207
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": []
}

4
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
WantedBy=multi-user.target

4
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;
fastcgi_param REMOTE_USER $remote_user;

5
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;
}
}

11
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) {

2
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;

188
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 = <<<USAGE_TEXT
Usage:
arsse.php daemon
arsse.php feed refresh-all
arsse.php feed refresh <n>
arsse.php conf save-defaults [<file>]
arsse.php user [list]
arsse.php user add <username> [<password>]
arsse.php user remove <username>
arsse.php user set-pass [--oldpass=<pass>] <username> [<password>]
arsse.php user auth <username> <password>
arsse.php user set-pass <username> [<password>]
[--oldpass=<pass>] [--fever]
arsse.php user unset-pass <username>
[--oldpass=<pass>] [--fever]
arsse.php user auth <username> <password> [--fever]
arsse.php import <username> [<file>]
[-f | --flat] [-r | --replace]
arsse.php export <username> [<file>]
[-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 <n>
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 [<file>]
Prints default configuration parameters to standard output, or to <file>
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 <username> [<password>]
Adds the user specified by <username>, with the provided password
<password>. If no password is specified, a random password will be
generated and printed to standard output.
user remove <username>
Removes the user specified by <username>. 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 <username> [<password>]
Changes <username>'s password to <password>. If no password is specified,
a random password will be generated and printed to standard output.
The --oldpass=<pass> 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 <username>
Unsets a user's password, effectively disabling their account. As with
password setting, the --oldpass and --fever options may be used.
user auth <username> <password>
Tests logging in as <username> with password <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 <username> [<file>]
Imports the feeds, folders, and tags found in the OPML formatted <file>
into the account of <username>. 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 <username> [<file>]
Exports <username>'s feeds, folders, and tags to the OPML file specified
by <file>, 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['<n>'], true);
case "feed refresh-all":
$this->getInstance(Service::class)->watch(false);
return 0;
case "conf save-defaults":
$file = $args['<file>'];
$file = ($file === "-" ? null : $file) ?? "php://output";
return (int) !($this->getConf())->exportFile($file, true);
$file = $this->resolveFile($args['<file>'], "w");
return (int) !$this->getInstance(Conf::class)->exportFile($file, true);
case "user":
$this->loadConf();
return $this->userManage($args);
case "export":
$u = $args['<username>'];
$file = $this->resolveFile($args['<file>'], "w");
return (int) !$this->getInstance(OPML::class)->exportFile($file, $u, ($args['--flat'] || $args['-f']));
case "import":
$u = $args['<username>'];
$file = $this->resolveFile($args['<file>'], "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["<username>"], $args["<password>"]);
case "set-pass":
return $this->userAddOrSetPassword("passwordSet", $args["<username>"], $args["<password>"], $args["--oldpass"]);
if ($args['--fever']) {
$passwd = $this->getInstance(Fever::class)->register($args["<username>"], $args["<password>"]);
if (is_null($args["<password>"])) {
echo $passwd.\PHP_EOL;
}
return 0;
} else {
return $this->userAddOrSetPassword("passwordSet", $args["<username>"], $args["<password>"], $args["--oldpass"]);
}
// no break
case "unset-pass":
if ($args['--fever']) {
$this->getInstance(Fever::class)->unregister($args["<username>"]);
} else {
Arsse::$user->passwordUnset($args["<username>"], $args["--oldpass"]);
}
return 0;
case "remove":
return (int) !Arsse::$user->remove($args["<username>"]);
case "auth":
return $this->userAuthenticate($args["<username>"], $args["<password>"]);
return $this->userAuthenticate($args["<username>"], $args["<password>"], $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 {

2
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":

56
lib/Context/Context.php

@ -0,0 +1,56 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Context;
class Context extends ExclusionContext {
/** @var ExclusionContext */
public $not;
public $limit = 0;
public $offset = 0;
public $unread;
public $starred;
public $labelled;
public $annotated;
public function __construct() {
$this->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);
}
}

200
lib/Misc/Context.php → 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);
}
}

1784
lib/Database.php

File diff suppressed because it is too large

62
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;

73
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;
}

10
lib/Db/ExceptionRetry.php

@ -0,0 +1,10 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db;
class ExceptionRetry extends \JKingWeb\Arsse\AbstractException {
}

19
lib/Db/MySQL/Driver.php

@ -41,7 +41,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
$this->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;
}
}

18
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);

4
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);
}
}

14
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]);
}
}

12
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;
}
}

16
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);

11
lib/Db/SQLite3/AbstractPDODriver.php

@ -0,0 +1,11 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db\SQLite3;
abstract class AbstractPDODriver extends Driver {
use \JKingWeb\Arsse\Db\PDODriver;
}

15
lib/Db/SQLite3/Driver.php

@ -17,6 +17,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
const TRANSACTIONAL_LOCKS = true;
const SQLITE_BUSY = 5;
const SQLITE_SCHEMA = 17;
const SQLITE_CONSTRAINT = 19;
const SQLITE_MISMATCH = 20;
@ -122,6 +123,10 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
}
public function schemaUpdate(int $to, string $basePath = null): bool {
if ($to == 1) {
// if we're initializing the database for the first time, switch to WAL mode
$this->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;
}
}

4
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:

40
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;
}
}
}
}

19
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;
}
}
}
}

14
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]);
}
}

58
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;

167
lib/ImportExport/AbstractImportExport.php

@ -0,0 +1,167 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\ImportExport;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\ExceptionInput as InputException;
use JKingWeb\Arsse\User\Exception as UserException;
abstract class AbstractImportExport {
public function import(string $user, string $data, bool $flat = false, bool $replace = false): bool {
if (!Arsse::$user->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);
}
}

10
lib/ImportExport/Exception.php

@ -0,0 +1,10 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\ImportExport;
class Exception extends \JKingWeb\Arsse\AbstractException {
}

155
lib/ImportExport/OPML.php

@ -0,0 +1,155 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\ImportExport;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User\Exception as UserException;
class OPML extends AbstractImportExport {
protected function parse(string $opml, bool $flat): array {
$d = new \DOMDocument;
if (!@$d->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();
}
}

94
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)) {

16
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 = [

415
lib/REST/Fever/API.php

@ -0,0 +1,415 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\REST\Fever;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Service;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Misc\ValueInfo as V;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\REST\Target;
use JKingWeb\Arsse\REST\Exception404;
use JKingWeb\Arsse\REST\Exception405;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\Response\JsonResponse;
use Zend\Diactoros\Response\XmlResponse;
use Zend\Diactoros\Response\EmptyResponse;
class API extends \JKingWeb\Arsse\REST\AbstractHandler {
const LEVEL = 3;
const GENERIC_ICON_TYPE = "image/png;base64";
const GENERIC_ICON_DATA = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAADUlEQVQYV2NgYGBgAAAABQABijPjAAAAAABJRU5ErkJggg==";
// GET parameters for which we only check presence: these will be converted to booleans
const PARAM_BOOL = ["groups", "feeds", "items", "favicons", "links", "unread_item_ids", "saved_item_ids"];
// GET parameters which contain meaningful values
const PARAM_GET = [
'api' => 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);
}
}

34
lib/REST/Fever/User.php

@ -0,0 +1,34 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\REST\Fever;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Db\ExceptionInput;
class User {
public function register(string $user, string $password = null): string {
$password = $password ?? Arsse::$user->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;
}
}
}

12
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);

34
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);
}
}

367
lib/REST/TinyTinyRSS/Search.php

@ -0,0 +1,367 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\REST\TinyTinyRSS;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Misc\Date;
class Search {
const STATE_BEFORE_TOKEN = 0;
const STATE_BEFORE_TOKEN_QUOTED = 1;
const STATE_IN_DATE = 2;
const STATE_IN_DATE_QUOTED = 3;
const STATE_IN_TOKEN_OR_TAG = 4;
const STATE_IN_TOKEN_OR_TAG_QUOTED = 5;
const STATE_IN_TOKEN = 6;
const STATE_IN_TOKEN_QUOTED = 7;
const FIELDS_BOOLEAN = [
"unread" => "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;
}
}
}
}
}

9
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;
}
}

19
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();
}
}

2
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;
}

15
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);
}
}

12
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',
];

11
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
"$base/vendor/bin/robo" "$roboCommand" -- "$@"
fi

10
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');

4
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';

2
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';

2
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';

41
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';

2
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');

2
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';

2
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';

2
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';

40
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';

5
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');

8
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';

2
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';

2
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';

78
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';

1
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";

179
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],
];
}
}

141
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.");
}
}
}

390
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];
}
}
}

44
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);
}
}

13
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() {

80
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() {

66
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() {

10
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() {

4
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());
}
}

21
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";

90
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"));
}
}

425
tests/cases/Database/SeriesTag.php

@ -0,0 +1,425 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Misc\Date;
use Phake;
trait SeriesTag {
protected function setUpSeriesTag() {
$this->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");
}
}

140
tests/cases/Database/SeriesToken.php

@ -0,0 +1,140 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Misc\Date;
use Phake;
trait SeriesToken {
protected function setUpSeriesToken() {
// set up the test data
$past = gmdate("Y-m-d H:i:s", strtotime("now - 1 minute"));
$future = gmdate("Y-m-d H:i:s", strtotime("now + 1 minute"));
$faroff = gmdate("Y-m-d H:i:s", strtotime("now + 1 hour"));
$old = gmdate("Y-m-d H:i:s", strtotime("now - 2 days"));
$this->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");
}
}

25
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");

2
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;
}

2
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") : [];

10
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());
}
}

14
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'"],

5
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());
}
}

131
tests/cases/ImportExport/TestFile.php

@ -0,0 +1,131 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\ImportExport;
use JKingWeb\Arsse\ImportExport\AbstractImportExport;
use JKingWeb\Arsse\ImportExport\Exception;
use org\bovigo\vfs\vfsStream;
/** @covers \JKingWeb\Arsse\ImportExport\AbstractImportExport */
class TestFile extends \JKingWeb\Arsse\Test\AbstractTest {
protected $vfs;
protected $path;
protected $proc;
public function setUp() {
self::clearData();
// create a mock Import/Export processor with stubbed underlying import/export routines
$this->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],
];
}
}

264
tests/cases/ImportExport/TestImportExport.php

@ -0,0 +1,264 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\ImportExport;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Db\SQLite3\Driver;
use JKingWeb\Arsse\ImportExport\AbstractImportExport;
use JKingWeb\Arsse\ImportExport\Exception;
use JKingWeb\Arsse\Test\Database;
/** @covers \JKingWeb\Arsse\ImportExport\AbstractImportExport */
class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest {
protected $drv;
protected $proc;
protected $checkTables = [
'arsse_folders' => ["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);
}
}

165
tests/cases/ImportExport/TestOPML.php

@ -0,0 +1,165 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\ImportExport;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Test\Result;
use JKingWeb\Arsse\ImportExport\OPML;
use JKingWeb\Arsse\ImportExport\Exception;
/** @covers \JKingWeb\Arsse\ImportExport\OPML<extended> */
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
<?xml version="1.0" encoding="utf-8"?>
<opml version="2.0">
<head/>
<body>
<outline text="Photography"/>
<outline text="Politics">
<outline text="Local">
<outline type="rss" text="Toronto Star" xmlUrl="http://example.com/2" category="Canada"/>
</outline>
<outline text="National">
<outline type="rss" text="CBC News" xmlUrl="http://example.com/4" category="Canada,Politics"/>
<outline type="rss" text="Ottawa Citizen" xmlUrl="http://example.com/5" category="Canada,Politics"/>
</outline>
</outline>
<outline text="Science">
<outline text="Rocketry">
<outline type="rss" text="NASA JPL" xmlUrl="http://example.com/1" category="Science etc"/>
</outline>
<outline type="rss" text="Ars Technica" xmlUrl="http://example.com/3" category="Science etc"/>
</outline>
<outline type="rss" text="Eurogamer" xmlUrl="http://example.com/6"/>
</body>
</opml>
OPML_EXPORT_SERIALIZATION;
protected $serializationFlat = <<<OPML_EXPORT_SERIALIZATION
<?xml version="1.0" encoding="utf-8"?>
<opml version="2.0">
<head/>
<body>
<outline type="rss" text="Ars Technica" xmlUrl="http://example.com/3" category="Science etc"/>
<outline type="rss" text="CBC News" xmlUrl="http://example.com/4" category="Canada,Politics"/>
<outline type="rss" text="Eurogamer" xmlUrl="http://example.com/6"/>
<outline type="rss" text="NASA JPL" xmlUrl="http://example.com/1" category="Science etc"/>
<outline type="rss" text="Ottawa Citizen" xmlUrl="http://example.com/5" category="Canada,Politics"/>
<outline type="rss" text="Toronto Star" xmlUrl="http://example.com/2" category="Canada"/>
</body>
</opml>
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],
]]],
];
}
}

63
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<extended> */
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));
}
}

13
tests/cases/REST/Fever/PDO/TestAPI.php

@ -0,0 +1,13 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\REST\Fever\PDO;
/** @covers \JKingWeb\Arsse\REST\Fever\API<extended>
* @group optional */
class TestAPI extends \JKingWeb\Arsse\TestCase\REST\Fever\TestAPI {
use \JKingWeb\Arsse\Test\PDOTest;
}

514
tests/cases/REST/Fever/TestAPI.php

@ -0,0 +1,514 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\REST\Fever;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Test\Result;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Db\Transaction;
use JKingWeb\Arsse\REST\Fever\API;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\ServerRequest;
use Zend\Diactoros\Response\JsonResponse;
use Zend\Diactoros\Response\XmlResponse;
use Zend\Diactoros\Response\EmptyResponse;
/** @covers \JKingWeb\Arsse\REST\Fever\API<extended> */
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' => '<p>Article content 1</p>',
'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' => '<p>Article content 2</p>',
'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' => '<p>Article content 3</p>',
'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' => '<p>Article content 4</p>',
'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' => '<p>Article content 5</p>',
'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' => '<p>Article content 1</p>',
'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' => '<p>Article content 2</p>',
'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' => '<p>Article content 3</p>',
'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' => '<p>Article content 4</p>',
'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' => '<p>Article content 5</p>',
'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("<response><items><item><id>101</id><feed_id>8</feed_id><title>Article title 1</title><author></author><html>&lt;p&gt;Article content 1&lt;/p&gt;</html><url>http://example.com/1</url><is_saved>0</is_saved><is_read>0</is_read><created_on_time>946684800</created_on_time></item><item><id>102</id><feed_id>8</feed_id><title>Article title 2</title><author></author><html>&lt;p&gt;Article content 2&lt;/p&gt;</html><url>http://example.com/2</url><is_saved>0</is_saved><is_read>1</is_read><created_on_time>946771200</created_on_time></item><item><id>103</id><feed_id>9</feed_id><title>Article title 3</title><author></author><html>&lt;p&gt;Article content 3&lt;/p&gt;</html><url>http://example.com/3</url><is_saved>1</is_saved><is_read>0</is_read><created_on_time>946857600</created_on_time></item><item><id>104</id><feed_id>9</feed_id><title>Article title 4</title><author></author><html>&lt;p&gt;Article content 4&lt;/p&gt;</html><url>http://example.com/4</url><is_saved>1</is_saved><is_read>1</is_read><created_on_time>946944000</created_on_time></item><item><id>105</id><feed_id>10</feed_id><title>Article title 5</title><author></author><html>&lt;p&gt;Article content 5&lt;/p&gt;</html><url>http://example.com/5</url><is_saved>0</is_saved><is_read>0</is_read><created_on_time>947030400</created_on_time></item></items><total_items>1024</total_items></response>");
$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);
}
}

94
tests/cases/REST/Fever/TestUser.php

@ -0,0 +1,94 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\REST\Fever;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\User\Exception as UserException;
use JKingWeb\Arsse\Db\Transaction;
use JKingWeb\Arsse\REST\Fever\User as FeverUser;
/** @covers \JKingWeb\Arsse\REST\Fever\User<extended> */
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],
];
}
}

36
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"]);
}
}

101
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],

125
tests/cases/REST/TinyTinyRSS/TestSearch.php

@ -0,0 +1,125 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\REST\TinyTinyRSS;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\REST\TinyTinyRSS\Search;
/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Search */
class TestSearch extends \JKingWeb\Arsse\Test\AbstractTest {
public function provideSearchStrings() {
return [
'Blank string' => ["", 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);
}
}

52
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");
}
}

38
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],
];
}
}

2
tests/docroot/Import/OPML/BrokenOPML.1.opml

@ -0,0 +1,2 @@
<html/>
<!-- Not an OPML document -->

2
tests/docroot/Import/OPML/BrokenOPML.2.opml

@ -0,0 +1,2 @@
<opml/>
<!-- Not body element -->

6
tests/docroot/Import/OPML/BrokenOPML.3.opml

@ -0,0 +1,6 @@
<opml>
<head>
<body/>
</head>
</opml>
<!-- No body as child of root -->

5
tests/docroot/Import/OPML/BrokenOPML.4.opml

@ -0,0 +1,5 @@
<opml>
<body/>
<body/>
</opml>
<!-- Only one body is allowed -->

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save