Browse Source

Merge remote-tracking branch 'remotes/origin/ttrss'

microsub
J. King 6 years ago
parent
commit
40944a9b58
  1. 1
      build.xml
  2. 9
      composer.json
  3. 274
      composer.lock
  4. 17
      dist/nginx.conf
  5. 6
      lib/AbstractException.php
  6. 8
      lib/Conf.php
  7. 798
      lib/Database.php
  8. 18
      lib/Db/AbstractResult.php
  9. 1
      lib/Db/AbstractStatement.php
  10. 50
      lib/Db/ResultAggregate.php
  11. 25
      lib/Db/ResultEmpty.php
  12. 1
      lib/Db/SQLite3/Result.php
  13. 51
      lib/Misc/Context.php
  14. 31
      lib/Misc/Query.php
  15. 24
      lib/REST.php
  16. 43
      lib/REST/NextCloudNews/V1_2.php
  17. 1469
      lib/REST/TinyTinyRSS/API.php
  18. 21
      lib/REST/TinyTinyRSS/Exception.php
  19. 36
      lib/REST/TinyTinyRSS/Icon.php
  20. 5
      lib/Service.php
  21. 10
      lib/User/ExceptionSession.php
  22. 14
      locale/en.php
  23. 48
      sql/SQLite3/1.sql
  24. 10
      tests/Db/SQLite3/Database/TestDatabaseLabelSQLite3.php
  25. 10
      tests/Db/SQLite3/Database/TestDatabaseSessionSQLite3.php
  26. 101
      tests/Db/TestResultAggregate.php
  27. 37
      tests/Db/TestResultEmpty.php
  28. 11
      tests/Misc/TestContext.php
  29. 5
      tests/Misc/TestValueInfo.php
  30. 86
      tests/REST/NextCloudNews/TestNCNV1_2.php
  31. 1784
      tests/REST/TinyTinyRSS/TestTinyTinyAPI.php
  32. 52
      tests/REST/TinyTinyRSS/TestTinyTinyIcon.php
  33. 385
      tests/lib/Database/SeriesArticle.php
  34. 28
      tests/lib/Database/SeriesCleanup.php
  35. 34
      tests/lib/Database/SeriesFolder.php
  36. 517
      tests/lib/Database/SeriesLabel.php
  37. 122
      tests/lib/Database/SeriesSession.php
  38. 58
      tests/lib/Database/SeriesSubscription.php
  39. 3
      tests/lib/Database/SeriesUser.php
  40. 25
      tests/lib/Result.php
  41. 24
      tests/phpunit.xml
  42. 21
      www/tt-rss/images/README
  43. BIN
      www/tt-rss/images/archive.png
  44. BIN
      www/tt-rss/images/feed.png
  45. BIN
      www/tt-rss/images/folder.png
  46. BIN
      www/tt-rss/images/fresh.png
  47. BIN
      www/tt-rss/images/label.png
  48. BIN
      www/tt-rss/images/time.png

1
build.xml

@ -8,6 +8,7 @@
<include name="sql/**"/>
<include name="locale/**"/>
<include name="dist/**"/>
<include name="www/**"/>
<include name="composer.*"/>
<include name="arsse.php"/>
<include name="CHANGELOG"/>

9
composer.json

@ -17,6 +17,8 @@
}
],
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"php": "^7.0",
"ext-intl": "*",
@ -24,7 +26,9 @@
"ext-hash": "*",
"fguillot/picofeed": ">=0.1.31",
"hosteurope/password-generator": "^1.0",
"docopt/docopt": "^1.0"
"docopt/docopt": "^1.0",
"jkingweb/druuid": "^3.0",
"phpseclib/phpseclib": "^2.0"
},
"require-dev": {
"mikey179/vfsStream": "^1.6",
@ -34,7 +38,8 @@
"phpdocumentor/phpdocumentor": "2.*",
"friendsofphp/php-cs-fixer": "^2.2",
"phing/phing": "^2.16",
"pear/archive_tar": "*"
"pear/archive_tar": "*",
"johnkary/phpunit-speedtrap": "^2.0"
},
"autoload": {
"psr-4": {

274
composer.lock

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically"
],
"content-hash": "125797db6f29f530c2f89209cc4f462d",
"content-hash": "2a8e077ce9d05d304c9041be28d1154e",
"packages": [
{
"name": "docopt/docopt",
@ -54,16 +54,16 @@
},
{
"name": "fguillot/picofeed",
"version": "v0.1.35",
"version": "v0.1.37",
"source": {
"type": "git",
"url": "https://github.com/miniflux/picoFeed.git",
"reference": "3a27b47de31eedec075c719f961783c5db7a7b08"
"reference": "402b7f07629577e7929625e78bc88d3d5831a22d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/miniflux/picoFeed/zipball/3a27b47de31eedec075c719f961783c5db7a7b08",
"reference": "3a27b47de31eedec075c719f961783c5db7a7b08",
"url": "https://api.github.com/repos/miniflux/picoFeed/zipball/402b7f07629577e7929625e78bc88d3d5831a22d",
"reference": "402b7f07629577e7929625e78bc88d3d5831a22d",
"shasum": ""
},
"require": {
@ -103,7 +103,7 @@
],
"description": "Modern library to handle RSS/Atom feeds",
"homepage": "https://github.com/miniflux/picoFeed",
"time": "2017-06-20T22:54:47+00:00"
"time": "2017-11-02T03:20:36+00:00"
},
{
"name": "hosteurope/password-generator",
@ -145,6 +145,143 @@
"description": "Password generator for generating policy-compliant passwords.",
"time": "2016-12-08T09:32:12+00:00"
},
{
"name": "jkingweb/druuid",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/JKingweb/DrUUID.git",
"reference": "ca88019069f03ee9c0b1bb6b0200f421bbc9607e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/JKingweb/DrUUID/zipball/ca88019069f03ee9c0b1bb6b0200f421bbc9607e",
"reference": "ca88019069f03ee9c0b1bb6b0200f421bbc9607e",
"shasum": ""
},
"require": {
"php": ">=5.3.0"
},
"suggest": {
"ext-bcmath": "Supported alternative to GMP on 32-bit systems",
"ext-gmp": "Recommended on 32-bit installations for time-base UUIDs",
"phpseclib/phpseclib": "Supported alternative to GMP or BC Math on 32-bit systems (either v1.x or v2.x)"
},
"type": "library",
"autoload": {
"psr-4": {
"JKingWeb\\DrUUID\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "J. King",
"email": "jking@jkingweb.ca",
"homepage": "https://jkingweb.ca/"
}
],
"description": "DrUUID RFC 4122 library for PHP",
"keywords": [
"uuid"
],
"time": "2017-02-09T14:17:01+00:00"
},
{
"name": "phpseclib/phpseclib",
"version": "2.0.7",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
"reference": "f4b6a522dfa1fd1e477c9cfe5909d5b31f098c0b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/f4b6a522dfa1fd1e477c9cfe5909d5b31f098c0b",
"reference": "f4b6a522dfa1fd1e477c9cfe5909d5b31f098c0b",
"shasum": ""
},
"require": {
"php": ">=5.3.3"
},
"require-dev": {
"phing/phing": "~2.7",
"phpunit/phpunit": "~4.0",
"sami/sami": "~2.0",
"squizlabs/php_codesniffer": "~2.0"
},
"suggest": {
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
},
"type": "library",
"autoload": {
"files": [
"phpseclib/bootstrap.php"
],
"psr-4": {
"phpseclib\\": "phpseclib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jim Wigginton",
"email": "terrafrost@php.net",
"role": "Lead Developer"
},
{
"name": "Patrick Monnerat",
"email": "pm@datasphere.ch",
"role": "Developer"
},
{
"name": "Andreas Fischer",
"email": "bantu@phpbb.com",
"role": "Developer"
},
{
"name": "Hans-Jürgen Petrich",
"email": "petrich@tronic-media.com",
"role": "Developer"
},
{
"name": "Graham Campbell",
"email": "graham@alt-three.com",
"role": "Developer"
}
],
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
"homepage": "http://phpseclib.sourceforge.net",
"keywords": [
"BigInteger",
"aes",
"asn.1",
"asn1",
"blowfish",
"crypto",
"cryptography",
"encryption",
"rsa",
"security",
"sftp",
"signature",
"signing",
"ssh",
"twofish",
"x.509",
"x509"
],
"time": "2017-10-23T05:04:54+00:00"
},
{
"name": "zendframework/zendxml",
"version": "1.0.2",
@ -623,16 +760,16 @@
},
{
"name": "friendsofphp/php-cs-fixer",
"version": "v2.2.8",
"version": "v2.2.9",
"source": {
"type": "git",
"url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git",
"reference": "aca23e791784eade7b377d578d6dfc6fcf1398d2"
"reference": "eace538b022a2b7db59ef7b5460cb8c66cb20b50"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/aca23e791784eade7b377d578d6dfc6fcf1398d2",
"reference": "aca23e791784eade7b377d578d6dfc6fcf1398d2",
"url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/eace538b022a2b7db59ef7b5460cb8c66cb20b50",
"reference": "eace538b022a2b7db59ef7b5460cb8c66cb20b50",
"shasum": ""
},
"require": {
@ -643,17 +780,17 @@
"gecko-packages/gecko-php-unit": "^2.0",
"php": "^5.3.6 || >=7.0 <7.3",
"sebastian/diff": "^1.4",
"symfony/console": "^2.4 || ^3.0",
"symfony/event-dispatcher": "^2.1 || ^3.0",
"symfony/filesystem": "^2.4 || ^3.0",
"symfony/finder": "^2.2 || ^3.0",
"symfony/options-resolver": "^2.6 || ^3.0",
"symfony/console": "^2.4 || ^3.0 || ^4.0",
"symfony/event-dispatcher": "^2.1 || ^3.0 || ^4.0",
"symfony/filesystem": "^2.4 || ^3.0 || ^4.0",
"symfony/finder": "^2.2 || ^3.0 || ^4.0",
"symfony/options-resolver": "^2.6 || ^3.0 || ^4.0",
"symfony/polyfill-php54": "^1.0",
"symfony/polyfill-php55": "^1.3",
"symfony/polyfill-php70": "^1.0",
"symfony/polyfill-php72": "^1.4",
"symfony/process": "^2.3 || ^3.0",
"symfony/stopwatch": "^2.5 || ^3.0"
"symfony/process": "^2.3 || ^3.0 || ^4.0",
"symfony/stopwatch": "^2.5 || ^3.0 || ^4.0"
},
"conflict": {
"hhvm": "<3.18"
@ -661,9 +798,9 @@
"require-dev": {
"johnkary/phpunit-speedtrap": "^1.0.1",
"justinrainbow/json-schema": "^5.0",
"php-coveralls/php-coveralls": "^1.0.2",
"phpunit/phpunit": "^4.8.35 || ^5.4.3",
"satooshi/php-coveralls": "^1.0",
"symfony/phpunit-bridge": "^3.2.2"
"symfony/phpunit-bridge": "^3.2.2 || ^4.0"
},
"suggest": {
"ext-mbstring": "For handling non-UTF8 characters in cache signature.",
@ -704,7 +841,7 @@
}
],
"description": "A tool to automatically fix PHP code style",
"time": "2017-09-29T15:07:49+00:00"
"time": "2017-11-02T12:46:49+00:00"
},
{
"name": "gecko-packages/gecko-php-unit",
@ -999,16 +1136,16 @@
},
{
"name": "jms/serializer",
"version": "1.9.0",
"version": "1.9.1",
"source": {
"type": "git",
"url": "https://github.com/schmittjoh/serializer.git",
"reference": "f4683f41ebf21e60667447bb49939bee35807c3c"
"reference": "e708d6ef549044974b60a57fdcec2fa165436d57"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/schmittjoh/serializer/zipball/f4683f41ebf21e60667447bb49939bee35807c3c",
"reference": "f4683f41ebf21e60667447bb49939bee35807c3c",
"url": "https://api.github.com/repos/schmittjoh/serializer/zipball/e708d6ef549044974b60a57fdcec2fa165436d57",
"reference": "e708d6ef549044974b60a57fdcec2fa165436d57",
"shasum": ""
},
"require": {
@ -1078,7 +1215,55 @@
"serialization",
"xml"
],
"time": "2017-09-28T15:17:28+00:00"
"time": "2017-10-27T07:15:54+00:00"
},
{
"name": "johnkary/phpunit-speedtrap",
"version": "v2.0.0-BETA1",
"source": {
"type": "git",
"url": "https://github.com/johnkary/phpunit-speedtrap.git",
"reference": "cbd785f67116c581f71705342cb316631e5a2be9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/johnkary/phpunit-speedtrap/zipball/cbd785f67116c581f71705342cb316631e5a2be9",
"reference": "cbd785f67116c581f71705342cb316631e5a2be9",
"shasum": ""
},
"require": {
"php": ">=7.0",
"phpunit/phpunit": "^6.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"JohnKary\\PHPUnit\\Listener\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "John Kary",
"email": "john@johnkary.net"
}
],
"description": "Find slow tests in your PHPUnit test suite",
"homepage": "https://github.com/johnkary/phpunit-speedtrap",
"keywords": [
"phpunit",
"profile",
"slow"
],
"time": "2017-03-17T12:23:15+00:00"
},
{
"name": "justinrainbow/json-schema",
@ -1315,37 +1500,40 @@
},
{
"name": "myclabs/deep-copy",
"version": "1.6.1",
"version": "1.7.0",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
"reference": "8e6e04167378abf1ddb4d3522d8755c5fd90d102"
"reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8e6e04167378abf1ddb4d3522d8755c5fd90d102",
"reference": "8e6e04167378abf1ddb4d3522d8755c5fd90d102",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e",
"reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e",
"shasum": ""
},
"require": {
"php": ">=5.4.0"
"php": "^5.6 || ^7.0"
},
"require-dev": {
"doctrine/collections": "1.*",
"phpunit/phpunit": "~4.1"
"doctrine/collections": "^1.0",
"doctrine/common": "^2.6",
"phpunit/phpunit": "^4.1"
},
"type": "library",
"autoload": {
"psr-4": {
"DeepCopy\\": "src/DeepCopy/"
}
},
"files": [
"src/DeepCopy/deep_copy.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Create deep copies (clones) of your objects",
"homepage": "https://github.com/myclabs/DeepCopy",
"keywords": [
"clone",
"copy",
@ -1353,7 +1541,7 @@
"object",
"object graph"
],
"time": "2017-04-12T18:52:22+00:00"
"time": "2017-10-19T19:58:43+00:00"
},
{
"name": "nikic/php-parser",
@ -2352,16 +2540,16 @@
},
{
"name": "phpunit/php-code-coverage",
"version": "5.2.2",
"version": "5.2.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
"reference": "8ed1902a57849e117b5651fc1a5c48110946c06b"
"reference": "8e1d2397d8adf59a3f12b2878a3aaa66d1ab189d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/8ed1902a57849e117b5651fc1a5c48110946c06b",
"reference": "8ed1902a57849e117b5651fc1a5c48110946c06b",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/8e1d2397d8adf59a3f12b2878a3aaa66d1ab189d",
"reference": "8e1d2397d8adf59a3f12b2878a3aaa66d1ab189d",
"shasum": ""
},
"require": {
@ -2370,7 +2558,7 @@
"php": "^7.0",
"phpunit/php-file-iterator": "^1.4.2",
"phpunit/php-text-template": "^1.2.1",
"phpunit/php-token-stream": "^1.4.11 || ^2.0",
"phpunit/php-token-stream": "^2.0",
"sebastian/code-unit-reverse-lookup": "^1.0.1",
"sebastian/environment": "^3.0",
"sebastian/version": "^2.0.1",
@ -2412,7 +2600,7 @@
"testing",
"xunit"
],
"time": "2017-08-03T12:40:43+00:00"
"time": "2017-11-03T13:47:33+00:00"
},
{
"name": "phpunit/php-file-iterator",
@ -5402,9 +5590,9 @@
}
],
"aliases": [],
"minimum-stability": "stable",
"minimum-stability": "dev",
"stability-flags": [],
"prefer-stable": false,
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^7.0",

17
dist/nginx.conf

@ -23,6 +23,7 @@ server {
include /usr/share/arsse/dist/nginx-fcgi.conf;
}
# NextCloud News protocol
location /index.php/apps/news/api {
try_files $uri @arsse_auth;
@ -30,4 +31,20 @@ server {
try_files $uri @arsse_no_auth;
}
}
# Tiny Tiny RSS protocol
location /tt-rss/api {
try_files $uri @arsse_no_auth;
}
# Tiny Tiny RSS feed icons
location /tt-rss/feed-icons/ {
try_files $uri @arsse_no_auth;
}
# Tiny Tiny RSS special-feed icons
location /tt-rss/images/ {
root /usr/share/arsse/www;
try_files $uri =404;
}
}

6
lib/AbstractException.php

@ -7,9 +7,10 @@ declare(strict_types=1);
namespace JKingWeb\Arsse;
abstract class AbstractException extends \Exception {
const CODES = [
const CODES = [
"Exception.uncoded" => -1,
"Exception.unknown" => 10000,
"Exception.constantUnknown" => 10001,
"ExceptionType.strictFailure" => 10011,
"ExceptionType.typeUnknown" => 10012,
"Lang/Exception.defaultFileMissing" => 10101,
@ -40,7 +41,7 @@ abstract class AbstractException extends \Exception {
"Db/Exception.savepointStatusUnknown" => 10225,
"Db/Exception.savepointInvalid" => 10226,
"Db/Exception.savepointStale" => 10227,
"Db/Exception.resultReused" => 10227,
"Db/Exception.resultReused" => 10228,
"Db/ExceptionInput.missing" => 10231,
"Db/ExceptionInput.whitespace" => 10232,
"Db/ExceptionInput.tooLong" => 10233,
@ -65,6 +66,7 @@ abstract class AbstractException extends \Exception {
"User/Exception.authMissing" => 10411,
"User/Exception.authFailed" => 10412,
"User/ExceptionAuthz.notAuthorized" => 10421,
"User/ExceptionSession.invalid" => 10431,
"Feed/Exception.invalidCertificate" => 10501,
"Feed/Exception.invalidUrl" => 10502,
"Feed/Exception.maxRedirect" => 10503,

8
lib/Conf.php

@ -32,10 +32,16 @@ class Conf {
public $userPreAuth = false;
/** @var integer Desired length of temporary user passwords */
public $userTempPasswordLength = 20;
/** @var string Period of inactivity after which log-in sessions should be considered invalid, as an ISO 8601 duration (default: 1 hour)
* @see https://en.wikipedia.org/wiki/ISO_8601#Durations */
public $userSessionTimeout = "PT1H";
/** @var string Maximum lifetime of log-in sessions regardless of activity, as an ISO 8601 duration (default: 24 hours);
* @see https://en.wikipedia.org/wiki/ISO_8601#Durations */
public $userSessionLifetime = "PT24H";
/** @var string Class of the background feed update service driver in use (Forking by default) */
public $serviceDriver = Service\Forking\Driver::class;
/** @var string The interval between checks for new feeds, as an ISO 8601 duration
/** @var string The interval between checks for new articles, as an ISO 8601 duration
* @see https://en.wikipedia.org/wiki/ISO_8601#Durations */
public $serviceFrequency = "PT2M";
/** @var integer Number of concurrent feed updates to perform */

798
lib/Database.php

@ -7,13 +7,20 @@ declare(strict_types=1);
namespace JKingWeb\Arsse;
use PasswordGenerator\Generator as PassGen;
use JKingWeb\DrUUID\UUID;
use JKingWeb\Arsse\Misc\Query;
use JKingWeb\Arsse\Misc\Context;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\ValueInfo;
class Database {
const SCHEMA_VERSION = 1;
const SCHEMA_VERSION = 2;
const LIMIT_ARTICLES = 50;
// articleList verbosity levels
const LIST_MINIMAL = 0; // only that metadata which is required for context matching
const LIST_CONSERVATIVE = 1; // base metadata plus anything that is not potentially large text
const LIST_TYPICAL = 2; // conservative, with the addition of content
const LIST_FULL = 3; // all possible fields
/** @var Db\Driver */
public $db;
@ -207,6 +214,10 @@ class Database {
"name" => "str",
];
list($setClause, $setTypes, $setValues) = $this->generateSet($properties, $valid);
if (!$setClause) {
// if no changes would actually be applied, just return
return $this->userPropertiesGet($user);
}
$this->db->prepare("UPDATE arsse_users set $setClause where id is ?", $setTypes, "str")->run($setValues, $user);
return $this->userPropertiesGet($user);
}
@ -228,6 +239,58 @@ class Database {
return true;
}
public function sessionCreate(string $user): string {
// If the user isn't authorized to perform this action then throw an exception.
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
// generate a new session ID and expiry date
$id = UUID::mint()->hex;
$expires = Date::add(Arsse::$conf->userSessionTimeout);
// save the session to the database
$this->db->prepare("INSERT INTO arsse_sessions(id,expires,user) values(?,?,?)", "str", "datetime", "str")->run($id, $expires, $user);
// return the ID
return $id;
}
public function sessionDestroy(string $user, string $id): bool {
// If the user isn't authorized to perform this action then throw an exception.
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
// delete the session and report success.
return (bool) $this->db->prepare("DELETE FROM arsse_sessions where id is ? and user is ?", "str", "str")->run($id, $user)->changes();
}
public function sessionResume(string $id): array {
$maxAge = Date::sub(Arsse::$conf->userSessionLifetime);
$out = $this->db->prepare("SELECT id,created,expires,user from arsse_sessions where id is ? and expires > CURRENT_TIMESTAMP and created > ?", "str", "datetime")->run($id, $maxAge)->getRow();
// if the session does not exist or is expired, throw an exception
if (!$out) {
throw new User\ExceptionSession("invalid", $id);
}
// if we're more than half-way from the session expiring, renew it
if ($this->sessionExpiringSoon(Date::normalize($out['expires'], "sql"))) {
$expires = Date::add(Arsse::$conf->userSessionTimeout);
$this->db->prepare("UPDATE arsse_sessions set expires = ? where id is ?", "datetime", "str")->run($expires, $id);
}
return $out;
}
public function sessionCleanup(): int {
$maxAge = Date::sub(Arsse::$conf->userSessionLifetime);
return $this->db->prepare("DELETE FROM arsse_sessions where expires < CURRENT_TIMESTAMP or created < ?", "datetime")->run($maxAge)->changes();
}
protected function sessionExpiringSoon(\DateTimeInterface $expiry): bool {
// calculate half the session timeout as a number of seconds
$now = time();
$max = Date::add(Arsse::$conf->userSessionTimeout, $now)->getTimestamp();
$diff = intdiv($max - $now, 2);
// determine if the expiry time is less than half the session timeout into the future
return (($now + $diff) >= $expiry->getTimestamp());
}
public function folderAdd(string $user, array $data): int {
// If the user isn't authorized to perform this action then throw an exception.
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
@ -249,15 +312,22 @@ class Database {
}
// check to make sure the parent exists, if one is specified
$parent = $this->folderValidateId($user, $parent)['id'];
// if we're not returning a recursive list we can use a simpler query
$q = new Query(
"SELECT
id,name,parent,
(select count(*) from arsse_folders as parents where parents.parent is arsse_folders.id) as children,
(select count(*) from arsse_subscriptions where folder is arsse_folders.id) as feeds
FROM arsse_folders"
);
if (!$recursive) {
return $this->db->prepare("SELECT id,name,parent from arsse_folders where owner is ? and parent is ?", "str", "int")->run($user, $parent);
$q->setWhere("owner is ?", "str", $user);
$q->setWhere("parent is ?", "int", $parent);
} else {
return $this->db->prepare(
"WITH RECURSIVE folders(id) as (SELECT id from arsse_folders where owner is ? and parent is ? union select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id) ".
"SELECT id,name,parent from arsse_folders where id in (SELECT id from folders) order by name",
"str", "int")->run($user, $parent);
$q->setCTE("folders", "SELECT id from arsse_folders where owner is ? and parent is ? union select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id", ["str", "int"], [$user, $parent]);
$q->setWhere("id in (SELECT id from folders)");
}
$q->setOrder("name");
return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
}
public function folderRemove(string $user, $id): bool {
@ -265,7 +335,7 @@ class Database {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "folder", 'id' => $id, 'type' => "int > 0"]);
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "folder", 'type' => "int > 0"]);
}
$changes = $this->db->prepare("DELETE FROM arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $id)->changes();
if (!$changes) {
@ -279,7 +349,7 @@ class Database {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "folder", 'id' => $id, 'type' => "int > 0"]);
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "folder", 'type' => "int > 0"]);
}
$props = $this->db->prepare("SELECT id,name,parent from arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $id)->getRow();
if (!$props) {
@ -313,7 +383,7 @@ class Database {
// if a new parent is specified, validate it
$in['parent'] = $this->folderValidateMove($user, (int) $id, $data['parent']);
} else {
// if neither was specified, do nothing
// if no changes would actually be applied, just return
return false;
}
$valid = [
@ -438,7 +508,7 @@ class Database {
return $this->db->prepare('INSERT INTO arsse_subscriptions(owner,feed) values(?,?)', 'str', 'int')->run($user, $feedID)->lastId();
}
public function subscriptionList(string $user, $folder = null, int $id = null): Db\Result {
public function subscriptionList(string $user, $folder = null, bool $recursive = true, int $id = null): Db\Result {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
@ -447,8 +517,9 @@ class Database {
// create a complex query
$q = new Query(
"SELECT
arsse_subscriptions.id,
url,favicon,source,folder,pinned,err_count,err_msg,order_type,added,
arsse_subscriptions.id as id,
feed,url,favicon,source,folder,pinned,err_count,err_msg,order_type,added,
arsse_feeds.updated as updated,
topmost.top as top_folder,
coalesce(arsse_subscriptions.title, arsse_feeds.title) as title,
(SELECT count(*) from arsse_articles where feed is arsse_subscriptions.feed) - (SELECT count(*) from arsse_marks where subscription is arsse_subscriptions.id and read is 1) as unread
@ -466,21 +537,42 @@ class Database {
// this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder
// if an ID is specified, add a suitable WHERE condition and bindings
$q->setWhere("arsse_subscriptions.id is ?", "int", $id);
} elseif ($folder) {
// if it does exist, add a common table expression to list it and its children so that we select from the entire subtree
} elseif ($folder && $recursive) {
// if a folder is specified and we're listing recursively, add a common table expression to list it and its children so that we select from the entire subtree
$q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent is folder", "int", $folder);
// add a suitable WHERE condition
$q->setWhere("folder in (select folder from folders)");
} elseif (!$recursive) {
// if we're not listing recursively, match against only the specified folder (even if it is null)
$q->setWhere("folder is ?", "int", $folder);
}
return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
}
public function subscriptionCount(string $user, $folder = null): int {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
// validate inputs
$folder = $this->folderValidateId($user, $folder)['id'];
// create a complex query
$q = new Query("SELECT count(*) from arsse_subscriptions");
$q->setWhere("owner is ?", "str", $user);
if ($folder) {
// if it does exist, add a common table expression to list it and its children so that we select from the entire subtree
$q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent is folder", "int", $folder);
// add a suitable WHERE condition
$q->setWhere("folder in (select folder from folders)");
}
return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
}
public function subscriptionRemove(string $user, $id): bool {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id, 'type' => "int > 0"]);
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'type' => "int > 0"]);
}
$changes = $this->db->prepare("DELETE from arsse_subscriptions where owner is ? and id is ?", "str", "int")->run($user, $id)->changes();
if (!$changes) {
@ -494,11 +586,11 @@ class Database {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id, 'type' => "int > 0"]);
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'type' => "int > 0"]);
}
// disable authorization checks for the list call
Arsse::$user->authorizationEnabled(false);
$sub = $this->subscriptionList($user, null, (int) $id)->getRow();
$sub = $this->subscriptionList($user, null, true, (int) $id)->getRow();
Arsse::$user->authorizationEnabled(true);
if (!$sub) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id]);
@ -537,14 +629,22 @@ class Database {
'pinned' => "strict bool",
];
list($setClause, $setTypes, $setValues) = $this->generateSet($data, $valid);
$out = (bool) $this->db->prepare("UPDATE arsse_subscriptions set $setClause where owner is ? and id is ?", $setTypes, "str", "int")->run($setValues, $user, $id)->changes();
if (!$setClause) {
// if no changes would actually be applied, just return
return false;
}
$out = (bool) $this->db->prepare("UPDATE arsse_subscriptions set $setClause, modified = CURRENT_TIMESTAMP where owner is ? and id is ?", $setTypes, "str", "int")->run($setValues, $user, $id)->changes();
$tr->commit();
return $out;
}
public function subscriptionFavicon(int $id): string {
return (string) $this->db->prepare("SELECT favicon from arsse_feeds join arsse_subscriptions on feed is arsse_feeds.id where arsse_subscriptions.id is ?", "int")->run($id)->getValue();
}
protected function subscriptionValidateId(string $user, $id, bool $subject = false): array {
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "feed", 'id' => $id, 'type' => "int > 0"]);
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "feed", 'type' => "int > 0"]);
}
$out = $this->db->prepare("SELECT id,feed from arsse_subscriptions where id is ? and owner is ?", "int", "str")->run($id, $user)->getRow();
if (!$out) {
@ -719,69 +819,129 @@ class Database {
)->run($feedID, $ids, $hashesUT, $hashesUC, $hashesTC);
}
public function articleList(string $user, Context $context = null): Db\Result {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
if (!$context) {
$context = new Context;
protected function articleQuery(string $user, Context $context, array $extraColumns = []): Query {
$extraColumns = implode(",", $extraColumns);
if (strlen($extraColumns)) {
$extraColumns .= ",";
}
$q = new Query(
"SELECT
$extraColumns
arsse_articles.id as id,
arsse_articles.url as url,
title,author,content,guid,
published as published_date,
edited as edited_date,
arsse_articles.feed as feed,
arsse_articles.modified as modified_date,
max(
modified,
coalesce((select modified from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)),'')
) as modified_date,
arsse_articles.modified,
coalesce((select modified from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)),''),
coalesce((select modified from arsse_label_members where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)),'')
) as marked_date,
NOT (select count(*) from arsse_marks where article is arsse_articles.id and read is 1 and subscription in (select sub from subscribed_feeds)) as unread,
(select count(*) from arsse_marks where article is arsse_articles.id and starred is 1 and subscription in (select sub from subscribed_feeds)) as starred,
(select max(id) from arsse_editions where article is arsse_articles.id) as edition,
subscribed_feeds.sub as subscription,
url_title_hash||':'||url_content_hash||':'||title_content_hash as fingerprint,
arsse_enclosures.url as media_url,
arsse_enclosures.type as media_type
FROM arsse_articles
join subscribed_feeds on arsse_articles.feed is subscribed_feeds.id
left join arsse_enclosures on arsse_enclosures.article is arsse_articles.id
"
subscribed_feeds.sub as subscription
FROM arsse_articles"
);
$q->setOrder("edition".($context->reverse ? " desc" : ""));
$q->setLimit($context->limit, $context->offset);
$q->setCTE("user(user)", "SELECT ?", "str", $user);
if ($context->subscription()) {
// if a subscription is specified, make sure it exists
$id = $this->subscriptionValidateId($user, $context->subscription)['feed'];
// add a basic CTE that will join in only the requested subscription
$q->setCTE("subscribed_feeds(id,sub)", "SELECT ?,?", ["int","int"], [$id,$context->subscription]);
$q->setCTE("subscribed_feeds(id,sub)", "SELECT ?,?", ["int","int"], [$id,$context->subscription], "join subscribed_feeds on feed is subscribed_feeds.id");
} elseif ($context->folder()) {
// if a folder is specified, make sure it exists
$this->folderValidateId($user, $context->folder);
// if it does exist, add a common table expression to list it and its children so that we select from the entire subtree
$q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent is folder", "int", $context->folder);
// add another CTE for the subscriptions within the folder
$q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner join folders on arsse_subscriptions.folder is folders.folder");
$q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner join folders on arsse_subscriptions.folder is folders.folder", [], [], "join subscribed_feeds on feed is subscribed_feeds.id");
} elseif ($context->folderShallow()) {
// if a shallow folder is specified, make sure it exists
$this->folderValidateId($user, $context->folderShallow);
// if it does exist, add a CTE with only its subscriptions (and not those of its descendents)
$q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner and coalesce(folder,0) is ?", "strict int", $context->folderShallow, "join subscribed_feeds on feed is subscribed_feeds.id");
} else {
// otherwise add a CTE for all the user's subscriptions
$q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner");
$q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner", [], [], "join subscribed_feeds on feed is subscribed_feeds.id");
}
if ($context->edition()) {
// if an edition is specified, filter for its previously identified article
$q->setWhere("arsse_articles.id is (select article from arsse_editions where id is ?)", "int", $context->edition);
} elseif ($context->article()) {
// if an article is specified, filter for it (it has already been validated above)
$q->setWhere("arsse_articles.id is ?", "int", $context->article);
}
if ($context->editions()) {
// if multiple specific editions have been requested, prepare a CTE to list them and their articles
if (!$context->editions) {
throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element
} elseif (sizeof($context->editions) > self::LIMIT_ARTICLES) {
throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore
}
list($inParams, $inTypes) = $this->generateIn($context->editions, "int");
$q->setCTE("requested_articles(id,edition)",
"SELECT article,id as edition from arsse_editions where edition in ($inParams)",
$inTypes,
$context->editions
);
$q->setWhere("arsse_articles.id in (select id from requested_articles)");
} elseif ($context->articles()) {
// if multiple specific articles have been requested, prepare a CTE to list them and their articles
if (!$context->articles) {
throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element
} elseif (sizeof($context->articles) > self::LIMIT_ARTICLES) {
throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore
}
list($inParams, $inTypes) = $this->generateIn($context->articles, "int");
$q->setCTE("requested_articles(id,edition)",
"SELECT id,(select max(id) from arsse_editions where article is arsse_articles.id) as edition from arsse_articles where arsse_articles.id in ($inParams)",
$inTypes,
$context->articles
);
$q->setWhere("arsse_articles.id in (select id from requested_articles)");
} else {
// if neither list is specified, mock an empty table
$q->setCTE("requested_articles(id,edition)", "SELECT 'empty','table' where 1 is 0");
}
// filter based on label by ID or name
if ($context->labelled()) {
// any label (true) or no label (false)
$q->setWhere((!$context->labelled ? "not " : "")."exists(select article from arsse_label_members where assigned is 1 and article is arsse_articles.id and subscription in (select sub from subscribed_feeds))");
} elseif ($context->label() || $context->labelName()) {
// specific label ID or name
if ($context->label()) {
$id = $this->labelValidateId($user, $context->label, false)['id'];
} else {
$id = $this->labelValidateId($user, $context->labelName, true)['id'];
}
$q->setWhere("exists(select article from arsse_label_members where assigned is 1 and article is arsse_articles.id and label is ?)", "int", $id);
}
// filter based on article or edition offset
if ($context->oldestArticle()) {
$q->setWhere("arsse_articles.id >= ?", "int", $context->oldestArticle);
}
if ($context->latestArticle()) {
$q->setWhere("arsse_articles.id <= ?", "int", $context->latestArticle);
}
// filter based on edition offset
if ($context->oldestEdition()) {
$q->setWhere("edition >= ?", "int", $context->oldestEdition);
}
if ($context->latestEdition()) {
$q->setWhere("edition <= ?", "int", $context->latestEdition);
}
// filter based on lastmod time
// filter based on time at which an article was changed by feed updates (modified), or by user action (marked)
if ($context->modifiedSince()) {
$q->setWhere("modified_date >= ?", "datetime", $context->modifiedSince);
}
if ($context->notModifiedSince()) {
$q->setWhere("modified_date <= ?", "datetime", $context->notModifiedSince);
}
if ($context->markedSince()) {
$q->setWhere("marked_date >= ?", "datetime", $context->markedSince);
}
if ($context->notMarkedSince()) {
$q->setWhere("marked_date <= ?", "datetime", $context->notMarkedSince);
}
// filter for un/read and un/starred status if specified
if ($context->unread()) {
$q->setWhere("unread is ?", "bool", $context->unread);
@ -789,159 +949,235 @@ class Database {
if ($context->starred()) {
$q->setWhere("starred is ?", "bool", $context->starred);
}
// perform the query and return results
return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
// filter based on whether the article has a note
if ($context->annotated()) {
$q->setWhere((!$context->annotated ? "not " : "")."exists(select modified from arsse_marks where article is arsse_articles.id and note <> '' and subscription in (select sub from subscribed_feeds))");
}
// return the query
return $q;
}
protected function articleChunk(Context $context): array {
$exception = "";
if ($context->editions()) {
// editions take precedence over articles
if (sizeof($context->editions) > self::LIMIT_ARTICLES) {
$exception = "editions";
}
} elseif ($context->articles()) {
if (sizeof($context->articles) > self::LIMIT_ARTICLES) {
$exception = "articles";
}
}
if ($exception) {
$out = [];
$list = array_chunk($context->$exception, self::LIMIT_ARTICLES);
foreach ($list as $chunk) {
$out[] = (clone $context)->$exception($chunk);
}
return $out;
} else {
return [];
}
}
public function articleMark(string $user, array $data, Context $context = null): bool {
public function articleList(string $user, Context $context = null, int $fields = self::LIST_FULL): Db\Result {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
if (!$context) {
$context = new Context;
$context = $context ?? new Context;
// if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result
if ($contexts = $this->articleChunk($context)) {
$out = [];
$tr = $this->begin();
foreach ($contexts as $context) {
$out[] = $this->articleList($user, $context, $fields);
}
$tr->commit();
return new Db\ResultAggregate(...$out);
} else {
$columns = [];
switch ($fields) {
// NOTE: the cases all cascade into each other: a given verbosity level is always a superset of the previous one
case self::LIST_FULL: // everything
$columns = array_merge($columns,[
"(select note from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)) as note",
]);
case self::LIST_TYPICAL: // conservative, plus content
$columns = array_merge($columns,[
"content",
"arsse_enclosures.url as media_url", // enclosures are potentially large due to data: URLs
"arsse_enclosures.type as media_type", // FIXME: enclosures should eventually have their own fetch method
]);
case self::LIST_CONSERVATIVE: // base metadata, plus anything that is not likely to be large text
$columns = array_merge($columns,[
"arsse_articles.url as url",
"arsse_articles.title as title",
"(select coalesce(arsse_subscriptions.title,arsse_feeds.title) from arsse_feeds join arsse_subscriptions on arsse_subscriptions.feed is arsse_feeds.id where arsse_feeds.id is arsse_articles.feed) as subscription_title",
"author",
"guid",
"published as published_date",
"edited as edited_date",
"url_title_hash||':'||url_content_hash||':'||title_content_hash as fingerprint",
]);
case self::LIST_MINIMAL: // base metadata (always included: required for context matching)
$columns = array_merge($columns,[
// id, subscription, feed, modified_date, marked_date, unread, starred, edition
"edited as edited_date",
]);
break;
default:
throw new Exception("constantUnknown", $fields);
}
$q = $this->articleQuery($user, $context, $columns);
$q->setOrder("edited_date".($context->reverse ? " desc" : ""));
$q->setOrder("edition".($context->reverse ? " desc" : ""));
$q->setJoin("left join arsse_enclosures on arsse_enclosures.article is arsse_articles.id");
// perform the query and return results
return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
}
// sanitize input
$values = [
isset($data['read']) ? $data['read'] : null,
isset($data['starred']) ? $data['starred'] : null,
];
// the two queries we want to execute to make the requested changes
$queries = [
"UPDATE arsse_marks
set
read = case when (select honour_read from target_articles where target_articles.id is article) is 1 then (select read from target_values) else read end,
starred = coalesce((select starred from target_values),starred),
modified = CURRENT_TIMESTAMP
WHERE
subscription in (select sub from subscribed_feeds)
and article in (select id from target_articles where to_insert is 0 and (honour_read is 1 or honour_star is 1))",
"INSERT INTO arsse_marks(subscription,article,read,starred)
select
(select id from arsse_subscriptions join user on user is owner where arsse_subscriptions.feed is target_articles.feed),
id,
coalesce((select read from target_values) * honour_read,0),
coalesce((select starred from target_values),0)
from target_articles where to_insert is 1 and (honour_read is 1 or honour_star is 1)"
];
$out = 0;
// wrap this UPDATE and INSERT together into a transaction
$tr = $this->begin();
// if an edition context is specified, make sure it's valid
if ($context->edition()) {
// make sure the edition exists
$edition = $this->articleValidateEdition($user, $context->edition);
// if the edition is not the latest, do not mark the read flag
if (!$edition['current']) {
$values[0] = null;
}
public function articleCount(string $user, Context $context = null): int {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
$context = $context ?? new Context;
// if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result
if ($contexts = $this->articleChunk($context)) {
$out = 0;
$tr = $this->begin();
foreach ($contexts as $context) {
$out += $this->articleCount($user, $context);
}
} elseif ($context->article()) {
// otherwise if an article context is specified, make sure it's valid
$this->articleValidateId($user, $context->article);
}
// execute each query in sequence
foreach ($queries as $query) {
// first build the query which will select the target articles; we will later turn this into a CTE for the actual query that manipulates the articles
$q = new Query(
"SELECT
arsse_articles.id as id,
feed,
(select max(id) from arsse_editions where article is arsse_articles.id) as edition,
max(arsse_articles.modified,
coalesce((select modified from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)),'')
) as modified_date,
(not exists(select article from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds))) as to_insert,
((select read from target_values) is not null and (select read from target_values) is not (coalesce((select read from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)),0)) and (not exists(select * from requested_articles) or (select max(id) from arsse_editions where article is arsse_articles.id) in (select edition from requested_articles))) as honour_read,
((select starred from target_values) is not null and (select starred from target_values) is not (coalesce((select starred from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)),0))) as honour_star
FROM arsse_articles"
);
// common table expression for the affected user
$q->setCTE("user(user)", "SELECT ?", "str", $user);
// common table expression with the values to set
$q->setCTE("target_values(read,starred)", "SELECT ?,?", ["bool","bool"], $values);
if ($context->subscription()) {
// if a subscription is specified, make sure it exists
$id = $this->subscriptionValidateId($user, $context->subscription)['feed'];
// add a basic CTE that will join in only the requested subscription
$q->setCTE("subscribed_feeds(id,sub)", "SELECT ?,?", ["int","int"], [$id,$context->subscription], "join subscribed_feeds on feed is subscribed_feeds.id");
} elseif ($context->folder()) {
// if a folder is specified, make sure it exists
$this->folderValidateId($user, $context->folder);
// if it does exist, add a common table expression to list it and its children so that we select from the entire subtree
$q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent is folder", "int", $context->folder);
// add another CTE for the subscriptions within the folder
$q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner join folders on arsse_subscriptions.folder is folders.folder", [], [], "join subscribed_feeds on feed is subscribed_feeds.id");
} else {
// otherwise add a CTE for all the user's subscriptions
$q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner", [], [], "join subscribed_feeds on feed is subscribed_feeds.id");
$tr->commit();
return $out;
} else {
$q = $this->articleQuery($user, $context);
$q->pushCTE("selected_articles");
$q->setBody("SELECT count(*) from selected_articles");
return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
}
}
public function articleMark(string $user, array $data, Context $context = null): int {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
$context = $context ?? new Context;
// if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result
if ($contexts = $this->articleChunk($context)) {
$out = 0;
$tr = $this->begin();
foreach ($contexts as $context) {
$out += $this->articleMark($user, $data, $context);
}
$tr->commit();
return $out;
} else {
// sanitize input
$values = [
isset($data['read']) ? $data['read'] : null,
isset($data['starred']) ? $data['starred'] : null,
isset($data['note']) ? $data['note'] : null,
];
// the two queries we want to execute to make the requested changes
$queries = [
"UPDATE arsse_marks
set
read = case when (select honour_read from target_articles where target_articles.id is article) is 1 then (select read from target_values) else read end,
starred = coalesce((select starred from target_values),starred),
note = coalesce((select note from target_values),note),
modified = CURRENT_TIMESTAMP
WHERE
subscription in (select sub from subscribed_feeds)
and article in (select id from target_articles where to_insert is 0 and (honour_read is 1 or honour_star is 1 or (select note from target_values) is not null))",
"INSERT INTO arsse_marks(subscription,article,read,starred,note)
select
(select id from arsse_subscriptions join user on user is owner where arsse_subscriptions.feed is target_articles.feed),
id,
coalesce((select read from target_values) * honour_read,0),
coalesce((select starred from target_values),0),
coalesce((select note from target_values),'')
from target_articles where to_insert is 1 and (honour_read is 1 or honour_star is 1 or coalesce((select note from target_values),'') <> '')"
];
$out = 0;
// wrap this UPDATE and INSERT together into a transaction
$tr = $this->begin();
// if an edition context is specified, make sure it's valid
if ($context->edition()) {
// if an edition is specified, filter for its previously identified article
$q->setWhere("arsse_articles.id is ?", "int", $edition['article']);
} elseif ($context->article()) {
// if an article is specified, filter for it (it has already been validated above)
$q->setWhere("arsse_articles.id is ?", "int", $context->article);
}
if ($context->editions()) {
// if multiple specific editions have been requested, prepare a CTE to list them and their articles
if (!$context->editions) {
throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element
} elseif (sizeof($context->editions) > 50) {
throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => __FUNCTION__, 'max' => 50]); // must not have more than 50 array elements
}
list($inParams, $inTypes) = $this->generateIn($context->editions, "int");
$q->setCTE("requested_articles(id,edition)",
"SELECT article,id as edition from arsse_editions where edition in ($inParams)",
$inTypes,
$context->editions
);
$q->setWhere("arsse_articles.id in (select id from requested_articles)");
} elseif ($context->articles()) {
// if multiple specific articles have been requested, prepare a CTE to list them and their articles
if (!$context->articles) {
throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element
} elseif (sizeof($context->articles) > 50) {
throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => __FUNCTION__, 'max' => 50]); // must not have more than 50 array elements
// make sure the edition exists
$edition = $this->articleValidateEdition($user, $context->edition);
// if the edition is not the latest, do not mark the read flag
if (!$edition['current']) {
$values[0] = null;
}
list($inParams, $inTypes) = $this->generateIn($context->articles, "int");
$q->setCTE("requested_articles(id,edition)",
"SELECT id,(select max(id) from arsse_editions where article is arsse_articles.id) as edition from arsse_articles where arsse_articles.id in ($inParams)",
$inTypes,
$context->articles
);
$q->setWhere("arsse_articles.id in (select id from requested_articles)");
} else {
// if neither list is specified, mock an empty table
$q->setCTE("requested_articles(id,edition)", "SELECT 'empty','table' where 1 is 0");
}
// filter based on edition offset
if ($context->oldestEdition()) {
$q->setWhere("edition >= ?", "int", $context->oldestEdition);
}
if ($context->latestEdition()) {
$q->setWhere("edition <= ?", "int", $context->latestEdition);
}
// filter based on lastmod time
if ($context->modifiedSince()) {
$q->setWhere("modified_date >= ?", "datetime", $context->modifiedSince);
} elseif ($context->article()) {
// otherwise if an article context is specified, make sure it's valid
$this->articleValidateId($user, $context->article);
}
if ($context->notModifiedSince()) {
$q->setWhere("modified_date <= ?", "datetime", $context->notModifiedSince);
// execute each query in sequence
foreach ($queries as $query) {
// first build the query which will select the target articles; we will later turn this into a CTE for the actual query that manipulates the articles
$q = $this->articleQuery($user, $context, [
"(not exists(select article from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds))) as to_insert",
"((select read from target_values) is not null and (select read from target_values) is not (coalesce((select read from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)),0)) and (not exists(select * from requested_articles) or (select max(id) from arsse_editions where article is arsse_articles.id) in (select edition from requested_articles))) as honour_read",
"((select starred from target_values) is not null and (select starred from target_values) is not (coalesce((select starred from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)),0))) as honour_star",
]);
// common table expression with the values to set
$q->setCTE("target_values(read,starred,note)", "SELECT ?,?,?", ["bool","bool","str"], $values);
// push the current query onto the CTE stack and execute the query we're actually interested in
$q->pushCTE("target_articles");
$q->setBody($query);
$out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes();
}
// push the current query onto the CTE stack and execute the query we're actually interested in
$q->pushCTE("target_articles(id,feed,edition,modified_date,to_insert,honour_read,honour_star)");
$q->setBody($query);
$out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes();
// commit the transaction
$tr->commit();
return $out;
}
}
public function articleStarred(string $user): array {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
return $this->db->prepare(
"SELECT
count(*) as total,
coalesce(sum(not read),0) as unread,
coalesce(sum(read),0) as read
FROM (
select read from arsse_marks where starred is 1 and subscription in (select id from arsse_subscriptions where owner is ?)
)", "str"
)->run($user)->getRow();
}
public function articleLabelsGet(string $user, $id, bool $byName = false): array {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
$id = $this->articleValidateId($user, $id)['article'];
$out = $this->db->prepare("SELECT id,name from arsse_labels where owner is ? and exists(select id from arsse_label_members where article is ? and label is arsse_labels.id and assigned is 1)", "str", "int")->run($user, $id)->getAll();
if (!$out) {
return $out;
} else {
// flatten the result to return just the label ID or name
return array_column($out, !$byName ? "id" : "name");
}
// commit the transaction
$tr->commit();
return (bool) $out;
}
public function articleStarredCount(string $user): int {
public function articleCategoriesGet(string $user, $id): array {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
return $this->db->prepare("SELECT count(*) from arsse_marks where starred is 1 and subscription in (select id from arsse_subscriptions where owner is ?)", "str")->run($user)->getValue();
$id = $this->articleValidateId($user, $id)['article'];
$out = $this->db->prepare("SELECT name from arsse_categories where article is ? order by name", "int")->run($id)->getAll();
if (!$out) {
return $out;
} else {
// flatten the result
return array_column($out, "name");
}
}
public function articleCleanup(): bool {
@ -984,7 +1220,7 @@ class Database {
protected function articleValidateId(string $user, $id): array {
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "article", 'id' => $id, 'type' => "int > 0"]); // @codeCoverageIgnore
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "article", 'type' => "int > 0"]); // @codeCoverageIgnore
}
$out = $this->db->prepare(
"SELECT
@ -1005,7 +1241,7 @@ class Database {
protected function articleValidateEdition(string $user, int $id): array {
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "edition", 'id' => $id, 'type' => "int > 0"]); // @codeCoverageIgnore
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "edition", 'type' => "int > 0"]); // @codeCoverageIgnore
}
$out = $this->db->prepare(
"SELECT
@ -1030,9 +1266,7 @@ class Database {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
if (!$context) {
$context = new Context;
}
$context = $context ?? new Context;
$q = new Query("SELECT max(arsse_editions.id) from arsse_editions left join arsse_articles on article is arsse_articles.id left join arsse_feeds on arsse_articles.feed is arsse_feeds.id");
if ($context->subscription()) {
// if a subscription is specified, make sure it exists
@ -1045,4 +1279,194 @@ class Database {
}
return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
}
public function labelAdd(string $user, array $data): int {
// if the user isn't authorized to perform this action then throw an exception.
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
// validate the label name
$name = array_key_exists("name", $data) ? $data['name'] : "";
$this->labelValidateName($name, true);
// perform the insert
return $this->db->prepare("INSERT INTO arsse_labels(owner,name) values(?,?)", "str", "str")->run($user, $name)->lastId();
}
public function labelList(string $user, bool $includeEmpty = true): Db\Result {
// if the user isn't authorized to perform this action then throw an exception.
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
return $this->db->prepare(
"SELECT
id,name,
(select count(*) from arsse_label_members where label is id and assigned is 1) as articles,
(select count(*) from arsse_label_members
join arsse_marks on arsse_label_members.article is arsse_marks.article and arsse_label_members.subscription is arsse_marks.subscription
where label is id and assigned is 1 and read is 1
) as read
FROM arsse_labels where owner is ? and articles >= ? order by name
", "str", "int"
)->run($user, !$includeEmpty);
}
public function labelRemove(string $user, $id, bool $byName = false): bool {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
$this->labelValidateId($user, $id, $byName, false);
$field = $byName ? "name" : "id";
$type = $byName ? "str" : "int";
$changes = $this->db->prepare("DELETE FROM arsse_labels where owner is ? and $field is ?", "str", $type)->run($user, $id)->changes();
if (!$changes) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "label", 'id' => $id]);
}
return true;
}
public function labelPropertiesGet(string $user, $id, bool $byName = false): array {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
$this->labelValidateId($user, $id, $byName, false);
$field = $byName ? "name" : "id";
$type = $byName ? "str" : "int";
$out = $this->db->prepare(
"SELECT
id,name,
(select count(*) from arsse_label_members where label is id and assigned is 1) as articles,
(select count(*) from arsse_label_members
join arsse_marks on arsse_label_members.article is arsse_marks.article and arsse_label_members.subscription is arsse_marks.subscription
where label is id and assigned is 1 and read is 1
) as read
FROM arsse_labels where $field is ? and owner is ?
", $type, "str"
)->run($id, $user)->getRow();
if (!$out) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "label", 'id' => $id]);
}
return $out;
}
public function labelPropertiesSet(string $user, $id, array $data, bool $byName = false): bool {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
$this->labelValidateId($user, $id, $byName, false);
if (isset($data['name'])) {
$this->labelValidateName($data['name']);
}
$field = $byName ? "name" : "id";
$type = $byName ? "str" : "int";
$valid = [
'name' => "str",
];
list($setClause, $setTypes, $setValues) = $this->generateSet($data, $valid);
if (!$setClause) {
// if no changes would actually be applied, just return
return false;
}
$out = (bool) $this->db->prepare("UPDATE arsse_labels set $setClause, modified = CURRENT_TIMESTAMP where owner is ? and $field is ?", $setTypes, "str", $type)->run($setValues, $user, $id)->changes();
if (!$out) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "label", 'id' => $id]);
}
return $out;
}
public function labelArticlesGet(string $user, $id, bool $byName = false): array {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
// just do a syntactic check on the label ID
$this->labelValidateId($user, $id, $byName, false);
$field = !$byName ? "id" : "name";
$type = !$byName ? "int" : "str";
$out = $this->db->prepare("SELECT article from arsse_label_members join arsse_labels on label is id where assigned is 1 and $field is ? and owner is ?", $type, "str")->run($id, $user)->getAll();
if (!$out) {
// if no results were returned, do a full validation on the label ID
$this->labelValidateId($user, $id, $byName, true, true);
// if the validation passes, return the empty result
return $out;
} else {
// flatten the result to return just the article IDs in a simple array
return array_column($out, "article");
}
}
public function labelArticlesSet(string $user, $id, Context $context = null, bool $remove = false, bool $byName = false): int {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
// validate the label ID, and get the numeric ID if matching by name
$id = $this->labelValidateId($user, $id, $byName, true)['id'];
$context = $context ?? new Context;
$out = 0;
// wrap this UPDATE and INSERT together into a transaction
$tr = $this->begin();
// first update any existing entries with the removal or re-addition of their association
$q = $this->articleQuery($user, $context);
$q->setWhere("exists(select article from arsse_label_members where label is ? and article is arsse_articles.id)", "int", $id);
$q->pushCTE("target_articles");
$q->setBody(
"UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label is ? and assigned is not ? and article in (select id from target_articles)",
["bool","int","bool"],
[!$remove, $id, !$remove]
);
$out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes();
// next, if we're not removing, add any new entries that need to be added
if (!$remove) {
$q = $this->articleQuery($user, $context);
$q->setWhere("not exists(select article from arsse_label_members where label is ? and article is arsse_articles.id)", "int", $id);
$q->pushCTE("target_articles");
$q->setBody(
"INSERT INTO
arsse_label_members(label,article,subscription)
SELECT
?,id,
(select id from arsse_subscriptions join user on user is owner where arsse_subscriptions.feed is target_articles.feed)
FROM target_articles",
"int", $id
);
$out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes();
}
// commit the transaction
$tr->commit();
return $out;
}
protected function labelValidateId(string $user, $id, bool $byName, bool $checkDb = true, bool $subject = false): array {
if (!$byName && !ValueInfo::id($id)) {
// if we're not referring to a label by name and the ID is invalid, throw an exception
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "label", 'type' => "int > 0"]);
} elseif ($byName && !(ValueInfo::str($id) & ValueInfo::VALID)) {
// otherwise if we are referring to a label by name but the ID is not a string, also throw an exception
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "label", 'type' => "string"]);
} elseif ($checkDb) {
$field = !$byName ? "id" : "name";
$type = !$byName ? "int" : "str";
$l = $this->db->prepare("SELECT id,name from arsse_labels where $field is ? and owner is ?", $type, "str")->run($id, $user)->getRow();
if (!$l) {
throw new Db\ExceptionInput($subject ? "subjectMissing" : "idMissing", ["action" => $this->caller(), "field" => "label", 'id' => $id]);
} else {
return $l;
}
}
return [
'id' => !$byName ? $id : null,
'name' => $byName ? $id : null,
];
}
protected function labelValidateName($name): bool {
$info = ValueInfo::str($name);
if ($info & (ValueInfo::NULL | ValueInfo::EMPTY)) {
throw new Db\ExceptionInput("missing", ["action" => $this->caller(), "field" => "name"]);
} elseif ($info & ValueInfo::WHITE) {
throw new Db\ExceptionInput("whitespace", ["action" => $this->caller(), "field" => "name"]);
} elseif (!($info & ValueInfo::VALID)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "name", 'type' => "string"]);
} else {
return true;
}
}
}

18
lib/Db/AbstractResult.php

@ -13,17 +13,23 @@ abstract class AbstractResult implements Result {
// actual public methods
public function getValue() {
$this->next();
if ($this->valid()) {
$keys = array_keys($this->cur);
return $this->cur[array_shift($keys)];
$out = array_shift($this->cur);
$this->next();
return $out;
} else {
return null;
}
return null;
}
public function getRow() {
$this->next();
return ($this->valid() ? $this->cur : null);
if ($this->valid()) {
$out = $this->cur;
$this->next();
return $out;
} else {
return null;
}
}
public function getAll(): array {

1
lib/Db/AbstractStatement.php

@ -11,7 +11,6 @@ use JKingWeb\Arsse\Misc\Date;
abstract class AbstractStatement implements Statement {
protected $types = [];
protected $isNullable = [];
protected $values = ['pre' => [], 'post' => []];
abstract public function runArray(array $values = []): Result;

50
lib/Db/ResultAggregate.php

@ -0,0 +1,50 @@
<?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;
use JKingWeb\Arsse\Db\Exception;
class ResultAggregate extends AbstractResult {
protected $data;
protected $index = 0;
protected $cur = null;
// actual public methods
public function changes() {
return array_reduce($this->data, function($sum, $value) {return $sum + $value->changes();}, 0);
}
public function lastId() {
return $this->data[sizeof($this->data) - 1]->lastId();
}
// constructor/destructor
public function __construct(Result ...$result) {
$this->data = $result;
}
public function __destruct() {
$max = sizeof($this->data);
for ($a = 0; $a < $max; $a++) {
unset($this->data[$a]);
}
}
// PHP iterator methods
public function valid() {
while (!$this->cur && isset($this->data[$this->index])) {
$this->cur = $this->data[$this->index]->getRow();
if (!$this->cur) {
$this->index++;
}
}
return (bool) $this->cur;
}
}

25
lib/Db/ResultEmpty.php

@ -0,0 +1,25 @@
<?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;
use JKingWeb\Arsse\Db\Exception;
class ResultEmpty extends AbstractResult {
public function changes() {
return 0;
}
public function lastId() {
return 0;
}
// PHP iterator methods
public function valid() {
return false;
}
}

1
lib/Db/SQLite3/Result.php

@ -11,7 +11,6 @@ use JKingWeb\Arsse\Db\Exception;
class Result extends \JKingWeb\Arsse\Db\AbstractResult {
protected $st;
protected $set;
protected $pos = 0;
protected $cur = null;
protected $rows = 0;
protected $id = 0;

51
lib/Misc/Context.php

@ -14,17 +14,26 @@ class Context {
public $limit = 0;
public $offset = 0;
public $folder;
public $folderShallow;
public $subscription;
public $oldestArticle;
public $latestArticle;
public $oldestEdition;
public $latestEdition;
public $unread = false;
public $starred = false;
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 = [];
@ -66,10 +75,22 @@ class Context {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function folderShallow(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function subscription(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function latestArticle(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
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);
}
@ -96,6 +117,16 @@ class Context {
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);
}
public function edition(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
@ -117,4 +148,20 @@ class Context {
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function label(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function labelName(string $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);
}
}

31
lib/Misc/Query.php

@ -14,6 +14,9 @@ class Query {
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
@ -47,6 +50,15 @@ class Query {
return true;
}
public function setJoin(string $join, $types = null, $values = null): bool {
$this->qJoin[] = $join;
if (!is_null($types)) {
$this->tJoin[] = $types;
$this->vJoin[] = $values;
}
return true;
}
public function setWhere(string $where, $types = null, $values = null): bool {
$this->qWhere[] = $where;
if (!is_null($types)) {
@ -81,6 +93,9 @@ class Query {
$this->qWhere = [];
$this->tWhere = [];
$this->vWhere = [];
$this->qJoin = [];
$this->tJoin = [];
$this->vJoin = [];
$this->order = [];
$this->setLimit(0, 0);
if (strlen($join)) {
@ -105,11 +120,19 @@ class Query {
}
public function getTypes(): array {
return [$this->tCTE, $this->tBody, $this->tWhere];
return [$this->tCTE, $this->tBody, $this->tJoin, $this->tWhere];
}
public function getValues(): array {
return [$this->vCTE, $this->vBody, $this->vWhere];
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 {
@ -136,6 +159,10 @@ class Query {
// 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);

24
lib/REST.php

@ -20,15 +20,29 @@ class REST {
'strip' => '/index.php/apps/news/api/v1-2',
'class' => REST\NextCloudNews\V1_2::class,
],
'ttrss_api' => [ // Tiny Tiny RSS https://git.tt-rss.org/git/tt-rss/wiki/ApiReference
'match' => '/tt-rss/api/',
'strip' => '/tt-rss/api/',
'class' => REST\TinyTinyRSS\API::class,
],
'ttrss_icon' => [ // Tiny Tiny RSS feed icons
'match' => '/tt-rss/feed-icons/',
'strip' => '/tt-rss/feed-icons/',
'class' => REST\TinyTinyRSS\Icon::class,
],
// Other candidates:
// NextCloud News v2 https://github.com/nextcloud/news/blob/master/docs/externalapi/External-Api.md
// Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9
// Feedbin v2 https://github.com/feedbin/feedbin-api
// Tiny Tiny RSS https://tt-rss.org/gitlab/fox/tt-rss/wikis/ApiReference
// Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html
// Fever https://feedafever.com/api
// NewsBlur http://www.newsblur.com/api
// Feedbin v2 https://github.com/feedbin/feedbin-api
// Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9
// Miniflux https://github.com/miniflux/miniflux/blob/master/docs/json-rpc-api.markdown
// CommaFeed https://www.commafeed.com/api/
// NextCloud News v2 https://github.com/nextcloud/news/blob/master/docs/externalapi/External-Api.md
// Selfoss https://github.com/SSilence/selfoss/wiki/Restful-API-for-Apps-or-any-other-external-access
// BirdReader https://github.com/glynnbird/birdreader/blob/master/API.md
// Proprietary (centralized) entities:
// NewsBlur http://www.newsblur.com/api
// Feedly https://developer.feedly.com/
];
public function __construct() {

43
lib/REST/NextCloudNews/V1_2.php

@ -7,6 +7,7 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\REST\NextCloudNews;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Service;
use JKingWeb\Arsse\Misc\Context;
@ -381,7 +382,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
$out[] = $this->feedTranslate($sub);
}
$out = ['feeds' => $out];
$out['starredCount'] = Arsse::$db->articleStarredCount(Arsse::$user->id);
$out['starredCount'] = Arsse::$db->articleStarred(Arsse::$user->id)['total'];
$newest = Arsse::$db->editionLatest(Arsse::$user->id);
if ($newest) {
$out['newestItemId'] = $newest;
@ -508,11 +509,11 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
// whether to return only updated items
if ($data['lastModified']) {
$c->modifiedSince($data['lastModified']);
$c->markedSince($data['lastModified']);
}
// perform the fetch
try {
$items = Arsse::$db->articleList(Arsse::$user->id, $c);
$items = Arsse::$db->articleList(Arsse::$user->id, $c, Database::LIST_TYPICAL);
} catch (ExceptionInput $e) {
// ID of subscription or folder is not valid
return new Response(422);
@ -575,19 +576,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function articleMarkReadMulti(array $url, array $data): Response {
// determine whether to mark read or unread
$set = ($url[1]=="read");
// start a transaction and loop through the items
$t = Arsse::$db->begin();
$in = array_chunk($data['items'] ?? [], 50);
for ($a = 0; $a < sizeof($in); $a++) {
// initialize the matching context
$c = new Context;
$c->editions($in[$a]);
try {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => $set], $c);
} catch (ExceptionInput $e) {
}
// initialize the matching context
$c = new Context;
$c->editions($data['items'] ?? []);
try {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => $set], $c);
} catch (ExceptionInput $e) {
}
$t->commit();
return new Response(204);
}
@ -595,19 +590,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function articleMarkStarredMulti(array $url, array $data): Response {
// determine whether to mark starred or unstarred
$set = ($url[1]=="star");
// start a transaction and loop through the items
$t = Arsse::$db->begin();
$in = array_chunk(array_column($data['items'] ?? [], "guidHash"), 50);
for ($a = 0; $a < sizeof($in); $a++) {
// initialize the matching context
$c = new Context;
$c->articles($in[$a]);
try {
Arsse::$db->articleMark(Arsse::$user->id, ['starred' => $set], $c);
} catch (ExceptionInput $e) {
}
// initialize the matching context
$c = new Context;
$c->articles(array_column($data['items'] ?? [], "guidHash"));
try {
Arsse::$db->articleMark(Arsse::$user->id, ['starred' => $set], $c);
} catch (ExceptionInput $e) {
}
$t->commit();
return new Response(204);
}

1469
lib/REST/TinyTinyRSS/API.php

File diff suppressed because it is too large

21
lib/REST/TinyTinyRSS/Exception.php

@ -0,0 +1,21 @@
<?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;
class Exception extends \Exception {
protected $data = [];
public function __construct($msg = "UNSPECIFIED_ERROR", $data = [], $e = null) {
$this->data = $data;
parent::__construct($msg, 0, $e);
}
public function getData(): array {
$err = ['error' => $this->getMessage()];
return array_merge($err, $this->data, $err);
}
}

36
lib/REST/TinyTinyRSS/Icon.php

@ -0,0 +1,36 @@
<?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\Arsse;
use JKingWeb\Arsse\REST\Response;
class Icon extends \JKingWeb\Arsse\REST\AbstractHandler {
public function __construct() {
}
public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response {
if ($req->method != "GET") {
// only GET requests are allowed
return new Response(405, "", "", ["Allow: GET"]);
} elseif (!preg_match("<^(\d+)\.ico$>", $req->url, $match) || !((int) $match[1])) {
return new Response(404);
}
$url = Arsse::$db->subscriptionFavicon((int) $match[1]);
if ($url) {
// strip out anything after literal line-end characters; this is to mitigate a potential header (e.g. cookie) injection from the URL
if (($pos = strpos($url, "\r")) !== FALSE || ($pos = strpos($url, "\n")) !== FALSE) {
$url = substr($url, 0, $pos);
}
return new Response(301, "", "", ["Location: $url"]);
} else {
return new Response(404);
}
}
}

5
lib/Service.php

@ -88,7 +88,10 @@ class Service {
public static function cleanupPre(): bool {
// mark unsubscribed feeds as orphaned and delete orphaned feeds that are beyond their retention period
return Arsse::$db->feedCleanup();
Arsse::$db->feedCleanup();
// delete expired log-in sessions
Arsse::$db->sessionCleanup();
return true;
}
public static function cleanupPost(): bool {

10
lib/User/ExceptionSession.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\User;
class ExceptionSession extends Exception {
}

14
locale/en.php

@ -4,6 +4,17 @@
* See LICENSE and AUTHORS files for details */
return [
'API.TTRSS.Category.Uncategorized' => 'Uncategorized',
'API.TTRSS.Category.Special' => 'Special',
'API.TTRSS.Category.Labels' => 'Labels',
'API.TTRSS.Feed.All' => 'All articles',
'API.TTRSS.Feed.Fresh' => 'Fresh articles',
'API.TTRSS.Feed.Starred' => 'Starred articles',
'API.TTRSS.Feed.Published' => 'Published articles',
'API.TTRSS.Feed.Archived' => 'Archived articles',
'API.TTRSS.Feed.Read' => 'Recently read',
'API.TTRSS.FeedCount' => '{0, select, 1 {(1 feed)} other {({0} feeds)}}',
'Driver.Db.SQLite3.Name' => 'SQLite 3',
'Driver.Service.Curl.Name' => 'HTTP (curl)',
'Driver.Service.Internal.Name' => 'Internal',
@ -74,6 +85,8 @@ return [
'Exception.JKingWeb/Arsse/Exception.uncoded' => 'The specified exception symbol {0} has no code specified in AbstractException.php',
// this should not usually be encountered
'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',
'Exception.JKingWeb/Arsse/ExceptionType.strictFailure' => 'Supplied value could not be normalized to {0, select,
1 {null}
2 {boolean}
@ -155,6 +168,7 @@ return [
}}
other {Authenticated user is not authorized to perform the action "{action}" on behalf of {user}}
}',
'Exception.JKingWeb/Arsse/User/ExceptionSession.invalid' => 'Session with ID {0} does not exist',
'Exception.JKingWeb/Arsse/Feed/Exception.invalidCertificate' => 'Could not download feed "{url}" because its server is serving an invalid SSL certificate',
'Exception.JKingWeb/Arsse/Feed/Exception.invalidUrl' => 'Feed URL "{url}" is invalid',
'Exception.JKingWeb/Arsse/Feed/Exception.maxRedirect' => 'Could not download feed "{url}" because its server reached its maximum number of HTTP redirections',

48
sql/SQLite3/1.sql

@ -0,0 +1,48 @@
-- SPDX-License-Identifier: MIT
-- Copyright 2017 J. King, Dustin Wilson et al.
-- See LICENSE and AUTHORS files for details
-- Sessions for Tiny Tiny RSS (and possibly others)
create table arsse_sessions (
id text primary key, -- UUID of session
created text not null default CURRENT_TIMESTAMP, -- Session start timestamp
expires text not null, -- Time at which session is no longer valid
user text not null references arsse_users(id) on delete cascade on update cascade -- user associated with the session
) without rowid;
-- User-defined article labels for Tiny Tiny RSS
create table arsse_labels (
id integer primary key, -- numeric ID
owner text not null references arsse_users(id) on delete cascade on update cascade, -- owning user
name text not null, -- label text
modified text not null default CURRENT_TIMESTAMP, -- time at which the label was last modified
unique(owner,name)
);
-- Labels assignments for articles
create table arsse_label_members (
label integer not null references arsse_labels(id) on delete cascade,
article integer not null references arsse_articles(id) on delete cascade,
subscription integer not null references arsse_subscriptions(id) on delete cascade, -- Subscription is included so that records are deleted when a subscription is removed
assigned boolean not null default 1,
modified text not null default CURRENT_TIMESTAMP,
primary key(label,article)
) without rowid;
-- alter marks table to add Tiny Tiny RSS' notes
alter table arsse_marks rename to arsse_marks_old;
create table arsse_marks(
article integer not null references arsse_articles(id) on delete cascade,
subscription integer not null references arsse_subscriptions(id) on delete cascade on update cascade,
read boolean not null default 0,
starred boolean not null default 0,
modified text not null default CURRENT_TIMESTAMP,
note text not null default '',
primary key(article,subscription)
);
insert into arsse_marks(article,subscription,read,starred,modified) select article,subscription,read,starred,modified from arsse_marks_old;
drop table arsse_marks_old;
-- set version marker
pragma user_version = 2;
update arsse_meta set value = '2' where key is 'schema_version';

10
tests/Db/SQLite3/Database/TestDatabaseLabelSQLite3.php

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace JKingWeb\Arsse;
/** @covers \JKingWeb\Arsse\Database<extended> */
class TestDatabaseLabelSQLite3 extends Test\AbstractTest {
use Test\Database\Setup;
use Test\Database\DriverSQLite3;
use Test\Database\SeriesLabel;
}

10
tests/Db/SQLite3/Database/TestDatabaseSessionSQLite3.php

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace JKingWeb\Arsse;
/** @covers \JKingWeb\Arsse\Database<extended> */
class TestDatabaseSessionSQLite3 extends Test\AbstractTest {
use Test\Database\Setup;
use Test\Database\DriverSQLite3;
use Test\Database\SeriesSession;
}

101
tests/Db/TestResultAggregate.php

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace JKingWeb\Arsse;
use JKingWeb\Arsse\Test\Result;
/** @covers \JKingWeb\Arsse\Db\ResultAggregate<extended> */
class TestResultAggregate extends Test\AbstractTest {
public function testGetChangeCountAndLastInsertId() {
$in = [
new Result([], 3, 4),
new Result([], 27, 10),
new Result([], 12, 2112),
];
$r = new Db\ResultAggregate(...$in);
$this->assertEquals(42, $r->changes());
$this->assertEquals(2112, $r->lastId());
}
public function testIterateOverResults() {
$in = [
new Result([['col' => 1]]),
new Result([['col' => 2]]),
new Result([['col' => 3]]),
];
$rows = [];
foreach (new Db\ResultAggregate(...$in) as $index => $row) {
$rows[$index] = $row['col'];
}
$this->assertEquals([0 => 1, 1 => 2, 2 => 3], $rows);
}
public function testIterateOverResultsTwice() {
$in = [
new Result([['col' => 1]]),
new Result([['col' => 2]]),
new Result([['col' => 3]]),
];
$rows = [];
$test = new Db\ResultAggregate(...$in);
foreach ($test as $row) {
$rows[] = $row['col'];
}
$this->assertEquals([1,2,3], $rows);
$this->assertException("resultReused", "Db");
foreach ($test as $row) {
$rows[] = $row['col'];
}
}
public function testGetSingleValues() {
$test = new Db\ResultAggregate(...[
new Result([['year' => 1867]]),
new Result([['year' => 1970]]),
new Result([['year' => 2112]]),
]);
$this->assertEquals(1867, $test->getValue());
$this->assertEquals(1970, $test->getValue());
$this->assertEquals(2112, $test->getValue());
$this->assertSame(null, $test->getValue());
}
public function testGetFirstValuesOnly() {
$test = new Db\ResultAggregate(...[
new Result([['year' => 1867, 'century' => 19]]),
new Result([['year' => 1970, 'century' => 20]]),
new Result([['year' => 2112, 'century' => 22]]),
]);
$this->assertEquals(1867, $test->getValue());
$this->assertEquals(1970, $test->getValue());
$this->assertEquals(2112, $test->getValue());
$this->assertSame(null, $test->getValue());
}
public function testGetRows() {
$test = new Db\ResultAggregate(...[
new Result([['album' => '2112', 'track' => '2112']]),
new Result([['album' => 'Clockwork Angels', 'track' => 'The Wreckers']]),
]);
$rows = [
['album' => '2112', 'track' => '2112'],
['album' => 'Clockwork Angels', 'track' => 'The Wreckers'],
];
$this->assertEquals($rows[0], $test->getRow());
$this->assertEquals($rows[1], $test->getRow());
$this->assertSame(null, $test->getRow());
}
public function testGetAllRows() {
$test = new Db\ResultAggregate(...[
new Result([['album' => '2112', 'track' => '2112']]),
new Result([['album' => 'Clockwork Angels', 'track' => 'The Wreckers']]),
]);
$rows = [
['album' => '2112', 'track' => '2112'],
['album' => 'Clockwork Angels', 'track' => 'The Wreckers'],
];
$this->assertEquals($rows, $test->getAll());
}
}

37
tests/Db/TestResultEmpty.php

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace JKingWeb\Arsse;
/** @covers \JKingWeb\Arsse\Db\ResultEmpty<extended> */
class TestResultEmpty extends Test\AbstractTest {
public function testGetChangeCountAndLastInsertId() {
$r = new Db\ResultEmpty;
$this->assertEquals(0, $r->changes());
$this->assertEquals(0, $r->lastId());
}
public function testIterateOverResults() {
$rows = [];
foreach (new Db\ResultEmpty as $index => $row) {
$rows[$index] = $row['col'];
}
$this->assertEquals([], $rows);
}
public function testGetSingleValues() {
$test = new Db\ResultEmpty;
$this->assertSame(null, $test->getValue());
}
public function testGetRows() {
$test = new Db\ResultEmpty;
$this->assertSame(null, $test->getRow());
}
public function testGetAllRows() {
$test = new Db\ResultEmpty;
$rows = [];
$this->assertEquals($rows, $test->getAll());
}
}

11
tests/Misc/TestContext.php

@ -28,19 +28,28 @@ class TestContext extends Test\AbstractTest {
'limit' => 10,
'offset' => 5,
'folder' => 42,
'folderShallow' => 42,
'subscription' => 2112,
'article' => 255,
'edition' => 65535,
'latestArticle' => 47,
'oldestArticle' => 1337,
'latestEdition' => 47,
'oldestEdition' => 1337,
'unread' => true,
'starred' => true,
'modifiedSince' => new \DateTime(),
'notModifiedSince' => new \DateTime(),
'markedSince' => new \DateTime(),
'notMarkedSince' => new \DateTime(),
'editions' => [1,2],
'articles' => [1,2],
'label' => 2112,
'labelName' => "Rush",
'labelled' => true,
'annotated' => true,
];
$times = ['modifiedSince','notModifiedSince'];
$times = ['modifiedSince','notModifiedSince','markedSince','notMarkedSince'];
$c = new Context;
foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) {
if ($m->isConstructor() || $m->isStatic()) {

5
tests/Misc/TestValueInfo.php

@ -83,6 +83,7 @@ class TestValueInfo extends Test\AbstractTest {
[0.5, I::FLOAT],
["2.5", I::FLOAT],
["0.5", I::FLOAT],
[" 1 ", I::VALID],
];
foreach ($tests as $test) {
list($value, $exp) = $test;
@ -322,7 +323,7 @@ class TestValueInfo extends Test\AbstractTest {
For each of these types, there is an expected output value, as well as a boolean indicating whether
the value should pass or fail a strict normalization. Conversion to DateTime is covered below by a different data set
*/
/* Input value null bool int float string array */
/* Input value null bool int float string array */
[null, [null,true], [false,false], [0, false], [0.0, false], ["", false], [[], false]],
["", [null,true], [false,true], [0, false], [0.0, false], ["", true], [[""], false]],
[1, [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[1], false]],
@ -434,7 +435,7 @@ class TestValueInfo extends Test\AbstractTest {
}
// DateTimeInterface tests
$tests = [
/* Input value microtime iso8601 iso8601m http sql date time unix float '!M j, Y (D)' *strtotime* (null) */
/* Input value microtime iso8601 iso8601m http sql date time unix float '!M j, Y (D)' *strtotime* (null) */
[null, null, null, null, null, null, null, null, null, null, null, null, ],
[$this->d("2010-01-01T00:00:00", 0, 0), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), ],
[$this->d("2010-01-01T00:00:00", 0, 1), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), ],

86
tests/REST/NextCloudNews/TestNCNV1_2.php

@ -500,7 +500,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
'newestItemId' => 4758915,
];
Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([]))->thenReturn(new Result($this->feeds['db']));
Phake::when(Arsse::$db)->articleStarredCount(Arsse::$user->id)->thenReturn(0)->thenReturn(5);
Phake::when(Arsse::$db)->articleStarred(Arsse::$user->id)->thenReturn(['total' => 0])->thenReturn(['total' => 5]);
Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id)->thenReturn(0)->thenReturn(4758915);
$exp = new Response(200, $exp1);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds")));
@ -686,11 +686,11 @@ class TestNCNV1_2 extends 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())->thenReturn($res);
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42))->thenThrow(new ExceptionInput("idMissing"));
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112))->thenThrow(new ExceptionInput("idMissing"));
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1))->thenThrow(new ExceptionInput("typeViolation"));
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1))->thenThrow(new ExceptionInput("typeViolation"));
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, $this->anything(), Database::LIST_TYPICAL)->thenReturn($res);
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("idMissing"));
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("idMissing"));
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("typeViolation"));
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("typeViolation"));
$exp = new Response(200, ['items' => $this->articles['rest']]);
// check the contents of the response
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/items"))); // first instance of base context
@ -711,23 +711,23 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->h->dispatch(new Request("GET", "/items", json_encode($in[10]), 'application/json'));
$this->h->dispatch(new Request("GET", "/items", json_encode($in[11]), 'application/json'));
// perform method verifications
Phake::verify(Arsse::$db, Phake::times(4))->articleList(Arsse::$user->id, (new Context)->reverse(true));
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42));
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112));
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1));
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1));
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->starred(true));
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(false)->limit(10)->oldestEdition(6)); // offset is one more than specified
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5)->latestEdition(4)); // offset is one less than specified
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->unread(true));
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->modifiedSince($t));
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5));
Phake::verify(Arsse::$db, Phake::times(4))->articleList(Arsse::$user->id, (new Context)->reverse(true), Database::LIST_TYPICAL);
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), Database::LIST_TYPICAL);
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), Database::LIST_TYPICAL);
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), Database::LIST_TYPICAL);
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), Database::LIST_TYPICAL);
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->starred(true), Database::LIST_TYPICAL);
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(false)->limit(10)->oldestEdition(6), Database::LIST_TYPICAL); // offset is one more than specified
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5)->latestEdition(4), Database::LIST_TYPICAL); // offset is one less than specified
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->unread(true), Database::LIST_TYPICAL);
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->markedSince($t), Database::LIST_TYPICAL);
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5), Database::LIST_TYPICAL);
}
public function testMarkAFolderRead() {
$read = ['read' => true];
$in = json_encode(['newestItemId' => 2112]);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(1)->latestEdition(2112))->thenReturn(true);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(1)->latestEdition(2112))->thenReturn(42);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(42)->latestEdition(2112))->thenThrow(new ExceptionInput("idMissing")); // folder doesn't exist
$exp = new Response(204);
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/folders/1/read", $in, 'application/json')));
@ -742,7 +742,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
public function testMarkASubscriptionRead() {
$read = ['read' => true];
$in = json_encode(['newestItemId' => 2112]);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(1)->latestEdition(2112))->thenReturn(true);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(1)->latestEdition(2112))->thenReturn(42);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(42)->latestEdition(2112))->thenThrow(new ExceptionInput("idMissing")); // subscription doesn't exist
$exp = new Response(204);
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/read", $in, 'application/json')));
@ -757,7 +757,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
public function testMarkAllItemsRead() {
$read = ['read' => true];
$in = json_encode(['newestItemId' => 2112]);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->latestEdition(2112))->thenReturn(true);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->latestEdition(2112))->thenReturn(42);
$exp = new Response(204);
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/read", $in, 'application/json')));
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/read?newestItemId=2112")));
@ -771,13 +771,13 @@ class TestNCNV1_2 extends Test\AbstractTest {
$unread = ['read' => false];
$star = ['starred' => true];
$unstar = ['starred' => false];
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->edition(1))->thenReturn(true);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->edition(1))->thenReturn(42);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->edition(42))->thenThrow(new ExceptionInput("subjectMissing")); // edition doesn't exist doesn't exist
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unread, (new Context)->edition(2))->thenReturn(true);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unread, (new Context)->edition(2))->thenReturn(42);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unread, (new Context)->edition(47))->thenThrow(new ExceptionInput("subjectMissing")); // edition doesn't exist doesn't exist
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->article(3))->thenReturn(true);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->article(3))->thenReturn(42);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->article(2112))->thenThrow(new ExceptionInput("subjectMissing")); // article doesn't exist doesn't exist
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->article(4))->thenReturn(true);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->article(4))->thenReturn(42);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->article(1337))->thenThrow(new ExceptionInput("subjectMissing")); // article doesn't exist doesn't exist
$exp = new Response(204);
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/1/read")));
@ -800,8 +800,6 @@ class TestNCNV1_2 extends Test\AbstractTest {
$in = [
["ook","eek","ack"],
range(100, 199),
range(100, 149),
range(150, 199),
];
$inStar = $in;
for ($a = 0; $a < sizeof($inStar); $a++) {
@ -809,11 +807,9 @@ class TestNCNV1_2 extends Test\AbstractTest {
$inStar[$a][$b] = ['feedId' => 2112, 'guidHash' => $inStar[$a][$b]];
}
}
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), $this->anything())->thenReturn(true);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), $this->anything())->thenReturn(42);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->editions([]))->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->editions($in[1]))->thenThrow(new ExceptionInput("tooLong")); // data model function limited to 50 items for multiples
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->articles([]))->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->articles($in[1]))->thenThrow(new ExceptionInput("tooLong")); // data model function limited to 50 items for multiples
$exp = new Response(204);
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/read/multiple")));
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple")));
@ -836,27 +832,19 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple", json_encode(['items' => $inStar[1]]), 'application/json')));
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/unstar/multiple", json_encode(['items' => $inStar[1]]), 'application/json')));
// ensure the data model was queried appropriately for read/unread
Phake::verify(Arsse::$db, Phake::times(2))->articleMark(Arsse::$user->id, $read, (new Context)->editions([]));
Phake::verify(Arsse::$db, Phake::times(2))->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[0]));
Phake::verify(Arsse::$db, Phake::times(0))->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[1]));
Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[2]));
Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[3]));
Phake::verify(Arsse::$db, Phake::times(2))->articleMark(Arsse::$user->id, $unread, (new Context)->editions([]));
Phake::verify(Arsse::$db, Phake::times(2))->articleMark(Arsse::$user->id, $unread, (new Context)->editions($in[0]));
Phake::verify(Arsse::$db, Phake::times(0))->articleMark(Arsse::$user->id, $unread, (new Context)->editions($in[1]));
Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $unread, (new Context)->editions($in[2]));
Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $unread, (new Context)->editions($in[3]));
Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $read, (new Context)->editions([]));
Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[0]));
Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[1]));
Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $unread, (new Context)->editions([]));
Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $unread, (new Context)->editions($in[0]));
Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $unread, (new Context)->editions($in[1]));
// ensure the data model was queried appropriately for star/unstar
Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->articles([]));
Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->articles($in[0]));
Phake::verify(Arsse::$db, Phake::times(0))->articleMark(Arsse::$user->id, $star, (new Context)->articles($in[1]));
Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->articles($in[2]));
Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->articles($in[3]));
Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->articles([]));
Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->articles($in[0]));
Phake::verify(Arsse::$db, Phake::times(0))->articleMark(Arsse::$user->id, $unstar, (new Context)->articles($in[1]));
Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->articles($in[2]));
Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->articles($in[3]));
Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $star, (new Context)->articles([]));
Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $star, (new Context)->articles($in[0]));
Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $star, (new Context)->articles($in[1]));
Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $unstar, (new Context)->articles([]));
Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $unstar, (new Context)->articles($in[0]));
Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $unstar, (new Context)->articles($in[1]));
}
public function testQueryTheServerStatus() {

1784
tests/REST/TinyTinyRSS/TestTinyTinyAPI.php

File diff suppressed because it is too large

52
tests/REST/TinyTinyRSS/TestTinyTinyIcon.php

@ -0,0 +1,52 @@
<?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;
use JKingWeb\Arsse\REST\Request;
use JKingWeb\Arsse\REST\Response;
use Phake;
/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Icon<extended> */
class TestTinyTinyIcon extends Test\AbstractTest {
protected $h;
public function setUp() {
$this->clearData();
Arsse::$conf = new Conf();
// create a mock user manager
// create a mock database interface
Arsse::$db = Phake::mock(Database::class);
$this->h = new REST\TinyTinyRSS\Icon();
}
public function tearDown() {
$this->clearData();
}
public function testRetrieveFavion() {
Phake::when(Arsse::$db)->subscriptionFavicon->thenReturn("");
Phake::when(Arsse::$db)->subscriptionFavicon(42)->thenReturn("http://example.com/favicon.ico");
Phake::when(Arsse::$db)->subscriptionFavicon(2112)->thenReturn("http://example.net/logo.png");
Phake::when(Arsse::$db)->subscriptionFavicon(1337)->thenReturn("http://example.org/icon.gif\r\nLocation: http://bad.example.com/");
// these requests should succeed
$exp = new Response(301, "", "", ["Location: http://example.com/favicon.ico"]);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "42.ico")));
$exp = new Response(301, "", "", ["Location: http://example.net/logo.png"]);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "2112.ico")));
$exp = new Response(301, "", "", ["Location: http://example.org/icon.gif"]);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "1337.ico")));
// these requests should fail
$exp = new Response(404);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "ook.ico")));
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "ook")));
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "47.ico")));
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "2112.png")));
// only GET is allowed
$exp = new Response(405, "", "", ["Allow: GET"]);
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "2112.ico")));
}
}

385
tests/lib/Database/SeriesArticle.php

@ -6,6 +6,7 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Misc\Context;
use JKingWeb\Arsse\Misc\Date;
@ -49,21 +50,22 @@ trait SeriesArticle {
'columns' => [
'id' => "int",
'url' => "str",
'title' => "str",
],
'rows' => [
[1,"http://example.com/1"],
[2,"http://example.com/2"],
[3,"http://example.com/3"],
[4,"http://example.com/4"],
[5,"http://example.com/5"],
[6,"http://example.com/6"],
[7,"http://example.com/7"],
[8,"http://example.com/8"],
[9,"http://example.com/9"],
[10,"http://example.com/10"],
[11,"http://example.com/11"],
[12,"http://example.com/12"],
[13,"http://example.com/13"],
[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' => [
@ -72,22 +74,23 @@ trait SeriesArticle {
'owner' => "str",
'feed' => "int",
'folder' => "int",
'title' => "str",
],
'rows' => [
[1,"john.doe@example.com",1,null],
[2,"john.doe@example.com",2,null],
[3,"john.doe@example.com",3,1],
[4,"john.doe@example.com",4,6],
[5,"john.doe@example.com",10,5],
[6,"jane.doe@example.com",1,null],
[7,"jane.doe@example.com",10,null],
[8,"john.doe@example.org",11,null],
[9,"john.doe@example.org",12,null],
[10,"john.doe@example.org",13,null],
[11,"john.doe@example.net",10,null],
[12,"john.doe@example.net",2,9],
[13,"john.doe@example.net",3,8],
[14,"john.doe@example.net",4,7],
[1, "john.doe@example.com",1, null,"Subscription 1"],
[2, "john.doe@example.com",2, null,null],
[3, "john.doe@example.com",3, 1,"Subscription 3"],
[4, "john.doe@example.com",4, 6,null],
[5, "john.doe@example.com",10, 5,"Subscription 5"],
[6, "jane.doe@example.com",1, null,null],
[7, "jane.doe@example.com",10,null,"Subscription 7"],
[8, "john.doe@example.org",11,null,null],
[9, "john.doe@example.org",12,null,"Subscription 9"],
[10,"john.doe@example.org",13,null,null],
[11,"john.doe@example.net",10,null,"Subscription 11"],
[12,"john.doe@example.net",2, 9,null],
[13,"john.doe@example.net",3, 8,"Subscription 13"],
[14,"john.doe@example.net",4, 7,null],
]
],
'arsse_articles' => [
@ -193,29 +196,76 @@ trait SeriesArticle {
'article' => "int",
'read' => "bool",
'starred' => "bool",
'modified' => "datetime"
'modified' => "datetime",
'note' => "str",
],
'rows' => [
[1, 1,1,1,'2000-01-01 00:00:00'],
[5, 19,1,0,'2000-01-01 00:00:00'],
[5, 20,0,1,'2010-01-01 00:00:00'],
[7, 20,1,0,'2010-01-01 00:00:00'],
[8, 102,1,0,'2000-01-02 02:00:00'],
[9, 103,0,1,'2000-01-03 03:00:00'],
[9, 104,1,1,'2000-01-04 04:00:00'],
[10,105,0,0,'2000-01-05 05:00:00'],
[11, 19,0,0,'2017-01-01 00:00:00'],
[11, 20,1,0,'2017-01-01 00:00:00'],
[12, 3,0,1,'2017-01-01 00:00:00'],
[12, 4,1,1,'2017-01-01 00:00:00'],
[1, 1,1,1,'2000-01-01 00:00:00',''],
[5, 19,1,0,'2016-01-01 00:00:00',''],
[5, 20,0,1,'2005-01-01 00:00:00',''],
[7, 20,1,0,'2010-01-01 00:00:00',''],
[8, 102,1,0,'2000-01-02 02:00:00','Note 2'],
[9, 103,0,1,'2000-01-03 03:00:00','Note 3'],
[9, 104,1,1,'2000-01-04 04:00:00','Note 4'],
[10,105,0,0,'2000-01-05 05:00:00',''],
[11, 19,0,0,'2017-01-01 00:00:00','ook'],
[11, 20,1,0,'2017-01-01 00:00:00','eek'],
[12, 3,0,1,'2017-01-01 00:00:00','ack'],
[12, 4,1,1,'2017-01-01 00:00:00','ach'],
[1, 2,0,0,'2010-01-01 00:00:00','Some Note'],
]
],
'arsse_categories' => [ // author-supplied categories
'columns' => [
'article' => "int",
'name' => "str",
],
'rows' => [
[19,"Fascinating"],
[19,"Logical"],
[20,"Interesting"],
[20,"Logical"],
],
],
'arsse_labels' => [
'columns' => [
'id' => "int",
'owner' => "str",
'name' => "str",
],
'rows' => [
[1,"john.doe@example.com","Interesting"],
[2,"john.doe@example.com","Fascinating"],
[3,"jane.doe@example.com","Boring"],
[4,"john.doe@example.com","Lonely"],
],
],
'arsse_label_members' => [
'columns' => [
'label' => "int",
'article' => "int",
'subscription' => "int",
'assigned' => "bool",
'modified' => "datetime",
],
'rows' => [
[1, 1,1,1,'2000-01-01 00:00:00'],
[2, 1,1,1,'2000-01-01 00:00:00'],
[1,19,5,1,'2000-01-01 00:00:00'],
[2,20,5,1,'2000-01-01 00:00:00'],
[1, 5,3,0,'2000-01-01 00:00:00'],
[2, 5,3,1,'2000-01-01 00:00:00'],
[4, 7,4,0,'2000-01-01 00:00:00'],
[4, 8,4,1,'2015-01-01 00:00:00'],
],
],
];
protected $matches = [
[
'id' => 101,
'url' => 'http://example.com/1',
'title' => 'Article title 1',
'subscription_title' => "Feed 11",
'author' => '',
'content' => '<p>Article content 1</p>',
'guid' => 'e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda',
@ -229,11 +279,13 @@ trait SeriesArticle {
'fingerprint' => 'f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6:fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4:18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207',
'media_url' => null,
'media_type' => null,
'note' => "",
],
[
'id' => 102,
'url' => 'http://example.com/2',
'title' => 'Article title 2',
'subscription_title' => "Feed 11",
'author' => '',
'content' => '<p>Article content 2</p>',
'guid' => '5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7',
@ -247,11 +299,13 @@ trait SeriesArticle {
'fingerprint' => '0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153:13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9:2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e',
'media_url' => "http://example.com/text",
'media_type' => "text/plain",
'note' => "Note 2",
],
[
'id' => 103,
'url' => 'http://example.com/3',
'title' => 'Article title 3',
'subscription_title' => "Subscription 9",
'author' => '',
'content' => '<p>Article content 3</p>',
'guid' => '31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92',
@ -265,11 +319,13 @@ trait SeriesArticle {
'fingerprint' => 'f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b:b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406:ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b',
'media_url' => "http://example.com/video",
'media_type' => "video/webm",
'note' => "Note 3",
],
[
'id' => 104,
'url' => 'http://example.com/4',
'title' => 'Article title 4',
'subscription_title' => "Subscription 9",
'author' => '',
'content' => '<p>Article content 4</p>',
'guid' => '804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180',
@ -283,11 +339,13 @@ trait SeriesArticle {
'fingerprint' => 'f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8:f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3:ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9',
'media_url' => "http://example.com/image",
'media_type' => "image/svg+xml",
'note' => "Note 4",
],
[
'id' => 105,
'url' => 'http://example.com/5',
'title' => 'Article title 5',
'subscription_title' => "Feed 13",
'author' => '',
'content' => '<p>Article content 5</p>',
'guid' => 'db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41',
@ -301,11 +359,32 @@ trait SeriesArticle {
'fingerprint' => 'd40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022:834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900:43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba',
'media_url' => "http://example.com/audio",
'media_type' => "audio/ogg",
'note' => "",
],
];
protected $fields = [
Database::LIST_MINIMAL => [
"id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date",
],
Database::LIST_CONSERVATIVE => [
"id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date",
"url", "title", "subscription_title", "author", "guid", "published_date", "fingerprint",
],
Database::LIST_TYPICAL => [
"id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date",
"url", "title", "subscription_title", "author", "guid", "published_date", "fingerprint",
"content", "media_url", "media_type",
],
Database::LIST_FULL => [
"id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date",
"url", "title", "subscription_title", "author", "guid", "published_date", "fingerprint",
"content", "media_url", "media_type",
"note",
],
];
public function setUpSeries() {
$this->checkTables = ['arsse_marks' => ["subscription","article","read","starred","modified"],];
$this->checkTables = ['arsse_marks' => ["subscription","article","read","starred","modified","note"],];
$this->user = "john.doe@example.net";
}
@ -321,12 +400,14 @@ trait SeriesArticle {
// get all items for user
$exp = [1,2,3,4,5,6,7,8,19,20];
$this->compareIds($exp, new Context);
$this->compareIds($exp, (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)));
// get items from a folder tree
$exp = [5,6,7,8];
$this->compareIds($exp, (new Context)->folder(1));
$this->compareIds([5,6,7,8], (new Context)->folder(1));
// get items from a leaf folder
$exp = [7,8];
$this->compareIds($exp, (new Context)->folder(6));
$this->compareIds([7,8], (new Context)->folder(6));
// get items from a non-leaf folder without descending
$this->compareIds([1,2,3,4], (new Context)->folderShallow(0));
$this->compareIds([5,6], (new Context)->folderShallow(1));
// get items from a single subscription
$exp = [19,20];
$this->compareIds($exp, (new Context)->subscription(5));
@ -342,13 +423,21 @@ trait SeriesArticle {
$this->compareIds([19], (new Context)->subscription(5)->latestEdition(19));
$this->compareIds([20], (new Context)->subscription(5)->oldestEdition(999));
$this->compareIds([20], (new Context)->subscription(5)->oldestEdition(1001));
// get items relative to modification date
// get items relative to article ID
$this->compareIds([1,2,3], (new Context)->latestArticle(3));
$this->compareIds([19,20], (new Context)->oldestArticle(19));
// get items relative to (feed) modification date
$exp = [2,4,6,8,20];
$this->compareIds($exp, (new Context)->modifiedSince("2005-01-01T00:00:00Z"));
$this->compareIds($exp, (new Context)->modifiedSince("2010-01-01T00:00:00Z"));
$exp = [1,3,5,7,19];
$this->compareIds($exp, (new Context)->notModifiedSince("2005-01-01T00:00:00Z"));
$this->compareIds($exp, (new Context)->notModifiedSince("2000-01-01T00:00:00Z"));
// get items relative to (user) modification date (both marks and labels apply)
$this->compareIds([8,19], (new Context)->markedSince("2014-01-01T00:00:00Z"));
$this->compareIds([2,4,6,8,19,20], (new Context)->markedSince("2010-01-01T00:00:00Z"));
$this->compareIds([1,2,3,4,5,6,7,20], (new Context)->notMarkedSince("2014-01-01T00:00:00Z"));
$this->compareIds([1,3,5,7], (new Context)->notMarkedSince("2005-01-01T00:00:00Z"));
// paged results
$this->compareIds([1], (new Context)->limit(1));
$this->compareIds([2], (new Context)->limit(1)->oldestEdition(1+1));
@ -359,6 +448,24 @@ trait SeriesArticle {
$this->compareIds([19], (new Context)->reverse(true)->limit(1)->latestEdition(1001-1));
$this->compareIds([8], (new Context)->reverse(true)->limit(1)->latestEdition(19-1));
$this->compareIds([7,6], (new Context)->reverse(true)->limit(2)->latestEdition(8-1));
// get articles by label ID
$this->compareIds([1,19], (new Context)->label(1));
$this->compareIds([1,5,20], (new Context)->label(2));
// get articles by label name
$this->compareIds([1,19], (new Context)->labelName("Interesting"));
$this->compareIds([1,5,20], (new Context)->labelName("Fascinating"));
// get articles with any or no label
$this->compareIds([1,5,8,19,20], (new Context)->labelled(true));
$this->compareIds([2,3,4,6,7], (new Context)->labelled(false));
// get a specific article or edition
$this->compareIds([20], (new Context)->article(20));
$this->compareIds([20], (new Context)->edition(1001));
// get multiple specific articles or editions
$this->compareIds([1,20], (new Context)->articles([1,20,50]));
$this->compareIds([1,20], (new Context)->editions([1,1001,50]));
// get articles base on whether or not they have notes
$this->compareIds([1,3,4,5,6,7,8,19,20], (new Context)->annotated(false));
$this->compareIds([2], (new Context)->annotated(true));
}
public function testListArticlesOfAMissingFolder() {
@ -374,6 +481,16 @@ trait SeriesArticle {
public function testListArticlesCheckingProperties() {
$this->user = "john.doe@example.org";
$this->assertResult($this->matches, Arsse::$db->articleList($this->user));
// check that the different fieldset groups return the expected columns
foreach ($this->fields as $constant => $columns) {
$test = array_keys(Arsse::$db->articleList($this->user, (new Context)->article(101), $constant)->getRow());
sort($columns);
sort($test);
$this->assertEquals($columns, $test, "Fields do not match expectation for verbosity $constant");
}
// check that an unknown fieldset produces an exception
$this->assertException("constantUnknown");
Arsse::$db->articleList($this->user, (new Context)->article(101), \PHP_INT_MAX);
}
public function testListArticlesWithoutAuthority() {
@ -401,10 +518,10 @@ trait SeriesArticle {
$state['arsse_marks']['rows'][8][4] = $now;
$state['arsse_marks']['rows'][10][2] = 1;
$state['arsse_marks']['rows'][10][4] = $now;
$state['arsse_marks']['rows'][] = [13,5,1,0,$now];
$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];
$state['arsse_marks']['rows'][] = [13,5,1,0,$now,''];
$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);
}
@ -427,10 +544,10 @@ trait SeriesArticle {
$state['arsse_marks']['rows'][8][4] = $now;
$state['arsse_marks']['rows'][9][3] = 1;
$state['arsse_marks']['rows'][9][4] = $now;
$state['arsse_marks']['rows'][] = [13,5,0,1,$now];
$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];
$state['arsse_marks']['rows'][] = [13,5,0,1,$now,''];
$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);
}
@ -459,10 +576,10 @@ trait SeriesArticle {
$state['arsse_marks']['rows'][9][4] = $now;
$state['arsse_marks']['rows'][10][2] = 1;
$state['arsse_marks']['rows'][10][4] = $now;
$state['arsse_marks']['rows'][] = [13,5,1,1,$now];
$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];
$state['arsse_marks']['rows'][] = [13,5,1,1,$now,''];
$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);
}
@ -477,10 +594,10 @@ trait SeriesArticle {
$state['arsse_marks']['rows'][9][4] = $now;
$state['arsse_marks']['rows'][11][2] = 0;
$state['arsse_marks']['rows'][11][4] = $now;
$state['arsse_marks']['rows'][] = [13,5,0,1,$now];
$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];
$state['arsse_marks']['rows'][] = [13,5,0,1,$now,''];
$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);
}
@ -495,10 +612,29 @@ trait SeriesArticle {
$state['arsse_marks']['rows'][10][4] = $now;
$state['arsse_marks']['rows'][11][3] = 0;
$state['arsse_marks']['rows'][11][4] = $now;
$state['arsse_marks']['rows'][] = [13,5,1,0,$now];
$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];
$state['arsse_marks']['rows'][] = [13,5,1,0,$now,''];
$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);
}
public function testSetNoteForAllArticles() {
Arsse::$db->articleMark($this->user, ['note'=>"New note"]);
$now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables);
$state['arsse_marks']['rows'][8][5] = "New note";
$state['arsse_marks']['rows'][8][4] = $now;
$state['arsse_marks']['rows'][9][5] = "New note";
$state['arsse_marks']['rows'][9][4] = $now;
$state['arsse_marks']['rows'][10][5] = "New note";
$state['arsse_marks']['rows'][10][4] = $now;
$state['arsse_marks']['rows'][11][5] = "New note";
$state['arsse_marks']['rows'][11][4] = $now;
$state['arsse_marks']['rows'][] = [13,5,0,0,$now,'New note'];
$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);
}
@ -506,10 +642,10 @@ trait SeriesArticle {
Arsse::$db->articleMark($this->user, ['read'=>true], (new Context)->folder(7));
$now = Date::transform(time(), "sql");
$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];
$state['arsse_marks']['rows'][] = [14,7,1,0,$now];
$state['arsse_marks']['rows'][] = [14,8,1,0,$now];
$state['arsse_marks']['rows'][] = [13,5,1,0,$now,''];
$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);
}
@ -517,8 +653,8 @@ trait SeriesArticle {
Arsse::$db->articleMark($this->user, ['read'=>true], (new Context)->folder(8));
$now = Date::transform(time(), "sql");
$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];
$state['arsse_marks']['rows'][] = [13,5,1,0,$now,''];
$state['arsse_marks']['rows'][] = [13,6,1,0,$now,''];
$this->compareExpectations($state);
}
@ -531,8 +667,8 @@ trait SeriesArticle {
Arsse::$db->articleMark($this->user, ['read'=>true], (new Context)->subscription(13));
$now = Date::transform(time(), "sql");
$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];
$state['arsse_marks']['rows'][] = [13,5,1,0,$now,''];
$state['arsse_marks']['rows'][] = [13,6,1,0,$now,''];
$this->compareExpectations($state);
}
@ -556,7 +692,7 @@ trait SeriesArticle {
$state = $this->primeExpectations($this->data, $this->checkTables);
$state['arsse_marks']['rows'][9][3] = 1;
$state['arsse_marks']['rows'][9][4] = $now;
$state['arsse_marks']['rows'][] = [14,7,0,1,$now];
$state['arsse_marks']['rows'][] = [14,7,0,1,$now,''];
$this->compareExpectations($state);
}
@ -569,7 +705,7 @@ trait SeriesArticle {
$state['arsse_marks']['rows'][9][4] = $now;
$state['arsse_marks']['rows'][11][2] = 0;
$state['arsse_marks']['rows'][11][4] = $now;
$state['arsse_marks']['rows'][] = [14,7,0,1,$now];
$state['arsse_marks']['rows'][] = [14,7,0,1,$now,''];
$this->compareExpectations($state);
}
@ -579,8 +715,7 @@ trait SeriesArticle {
}
public function testMarkTooManyMultipleArticles() {
$this->assertException("tooLong", "Db", "ExceptionInput");
Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, 51)));
$this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3))));
}
public function testMarkAMissingArticle() {
@ -603,7 +738,7 @@ trait SeriesArticle {
$state = $this->primeExpectations($this->data, $this->checkTables);
$state['arsse_marks']['rows'][9][3] = 1;
$state['arsse_marks']['rows'][9][4] = $now;
$state['arsse_marks']['rows'][] = [14,7,0,1,$now];
$state['arsse_marks']['rows'][] = [14,7,0,1,$now,''];
$this->compareExpectations($state);
}
@ -635,7 +770,7 @@ trait SeriesArticle {
$state['arsse_marks']['rows'][9][4] = $now;
$state['arsse_marks']['rows'][11][2] = 0;
$state['arsse_marks']['rows'][11][4] = $now;
$state['arsse_marks']['rows'][] = [14,7,0,1,$now];
$state['arsse_marks']['rows'][] = [14,7,0,1,$now,''];
$this->compareExpectations($state);
}
@ -645,8 +780,7 @@ trait SeriesArticle {
}
public function testMarkTooManyMultipleEditions() {
$this->assertException("tooLong", "Db", "ExceptionInput");
Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions(range(1, 51)));
$this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions(range(1, 51))));
}
public function testMarkAStaleEditionUnread() {
@ -701,15 +835,15 @@ trait SeriesArticle {
$state = $this->primeExpectations($this->data, $this->checkTables);
$state['arsse_marks']['rows'][8][3] = 1;
$state['arsse_marks']['rows'][8][4] = $now;
$state['arsse_marks']['rows'][] = [13,5,0,1,$now];
$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];
$state['arsse_marks']['rows'][] = [13,5,0,1,$now,''];
$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);
}
public function testMarkByLastModified() {
Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->modifiedSince('2017-01-01T00:00:00Z'));
public function testMarkByLastMarked() {
Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->markedSince('2017-01-01T00:00:00Z'));
$now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables);
$state['arsse_marks']['rows'][8][3] = 1;
@ -719,12 +853,12 @@ trait SeriesArticle {
$this->compareExpectations($state);
}
public function testMarkByNotLastModified() {
Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->notModifiedSince('2000-01-01T00:00:00Z'));
public function testMarkByNotLastMarked() {
Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->notMarkedSince('2000-01-01T00:00:00Z'));
$now = Date::transform(time(), "sql");
$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];
$state['arsse_marks']['rows'][] = [13,5,0,1,$now,''];
$state['arsse_marks']['rows'][] = [14,7,0,1,$now,''];
$this->compareExpectations($state);
}
@ -734,17 +868,30 @@ trait SeriesArticle {
Arsse::$db->articleMark($this->user, ['read'=>false]);
}
public function testCountStarredArticles() {
$this->assertSame(2, Arsse::$db->articleStarredCount("john.doe@example.com"));
$this->assertSame(2, Arsse::$db->articleStarredCount("john.doe@example.org"));
$this->assertSame(2, Arsse::$db->articleStarredCount("john.doe@example.net"));
$this->assertSame(0, Arsse::$db->articleStarredCount("jane.doe@example.com"));
public function testCountArticles() {
$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))));
}
public function testCountArticlesWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->articleCount($this->user);
}
public function testFetchStarredCounts() {
$exp1 = ['total' => 2, 'unread' => 1, 'read' => 1];
$exp2 = ['total' => 0, 'unread' => 0, 'read' => 0];
$this->assertSame($exp1, Arsse::$db->articleStarred("john.doe@example.com"));
$this->assertSame($exp2, Arsse::$db->articleStarred("jane.doe@example.com"));
}
public function testCountStarredArticlesWithoutAuthority() {
public function testFetchStarredCountsWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->articleStarredCount($this->user);
Arsse::$db->articleStarred($this->user);
}
public function testFetchLatestEdition() {
@ -762,4 +909,44 @@ trait SeriesArticle {
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->editionLatest($this->user);
}
public function testListTheLabelsOfAnArticle() {
$this->assertEquals([2,1], Arsse::$db->articleLabelsGet("john.doe@example.com", 1));
$this->assertEquals([2], Arsse::$db->articleLabelsGet("john.doe@example.com", 5));
$this->assertEquals([], Arsse::$db->articleLabelsGet("john.doe@example.com", 2));
$this->assertEquals(["Fascinating","Interesting"], Arsse::$db->articleLabelsGet("john.doe@example.com", 1, true));
$this->assertEquals(["Fascinating"], Arsse::$db->articleLabelsGet("john.doe@example.com", 5, true));
$this->assertEquals([], Arsse::$db->articleLabelsGet("john.doe@example.com", 2, true));
}
public function testListTheLabelsOfAMissingArticle() {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->articleLabelsGet($this->user, 101);
}
public function testListTheLabelsOfAnArticleWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->articleLabelsGet("john.doe@example.com", 1);
}
public function testListTheCategoriesOfAnArticle() {
$exp = ["Fascinating", "Logical"];
$this->assertSame($exp, Arsse::$db->articleCategoriesGet($this->user, 19));
$exp = ["Interesting", "Logical"];
$this->assertSame($exp, Arsse::$db->articleCategoriesGet($this->user, 20));
$exp = [];
$this->assertSame($exp, Arsse::$db->articleCategoriesGet($this->user, 4));
}
public function testListTheCategoriesOfAMissingArticle() {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->articleCategoriesGet($this->user, 101);
}
public function testListTheCategoriesOfAnArticleWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->articleCategoriesGet($this->user, 19);
}
}

28
tests/lib/Database/SeriesCleanup.php

@ -17,6 +17,8 @@ trait SeriesCleanup {
$daybefore = gmdate("Y-m-d H:i:s", strtotime("now - 2 days"));
$daysago = gmdate("Y-m-d H:i:s", strtotime("now - 7 days"));
$weeksago = gmdate("Y-m-d H:i:s", strtotime("now - 21 days"));
$soon = gmdate("Y-m-d H:i:s", strtotime("now + 1 minute"));
$faroff = gmdate("Y-m-d H:i:s", strtotime("now + 1 hour"));
$this->data = [
'arsse_users' => [
'columns' => [
@ -29,6 +31,21 @@ trait SeriesCleanup {
["john.doe@example.com", "", "John Doe"],
],
],
'arsse_sessions' => [
'columns' => [
'id' => "str",
'created' => "datetime",
'expires' => "datetime",
'user' => "str",
],
'rows' => [
["a", $nowish, $faroff, "jane.doe@example.com"], // not expired and recently created, thus kept
["b", $nowish, $soon, "jane.doe@example.com"], // not expired and recently created, thus kept
["c", $daysago, $soon, "jane.doe@example.com"], // created more than a day ago, thus deleted
["d", $nowish, $nowish, "jane.doe@example.com"], // recently created but expired, thus deleted
["e", $daysago, $nowish, "jane.doe@example.com"], // created more than a day ago and expired, thus deleted
],
],
'arsse_feeds' => [
'columns' => [
'id' => "int",
@ -169,4 +186,15 @@ trait SeriesCleanup {
]);
$this->compareExpectations($state);
}
public function testCleanUpExpiredSessions() {
Arsse::$db->sessionCleanup();
$state = $this->primeExpectations($this->data, [
'arsse_sessions' => ["id"]
]);
foreach ([3,4,5] as $id) {
unset($state['arsse_sessions']['rows'][$id - 1]);
}
$this->compareExpectations($state);
}
}

34
tests/lib/Database/SeriesFolder.php

@ -113,16 +113,16 @@ trait SeriesFolder {
public function testListRootFolders() {
$exp = [
['id' => 5, 'name' => "Politics", 'parent' => null],
['id' => 1, 'name' => "Technology", 'parent' => null],
['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0],
['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2],
];
$this->assertSame($exp, Arsse::$db->folderList("john.doe@example.com", null, false)->getAll());
$this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", null, false));
$exp = [
['id' => 4, 'name' => "Politics", 'parent' => null],
['id' => 4, 'name' => "Politics", 'parent' => null, 'children' => 0],
];
$this->assertSame($exp, Arsse::$db->folderList("jane.doe@example.com", null, false)->getAll());
$this->assertResult($exp, Arsse::$db->folderList("jane.doe@example.com", null, false));
$exp = [];
$this->assertSame($exp, Arsse::$db->folderList("admin@example.net", null, false)->getAll());
$this->assertResult($exp, Arsse::$db->folderList("admin@example.net", null, false));
Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderList");
Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "folderList");
Phake::verify(Arsse::$user)->authorize("admin@example.net", "folderList");
@ -130,21 +130,21 @@ trait SeriesFolder {
public function testListFoldersRecursively() {
$exp = [
['id' => 5, 'name' => "Politics", 'parent' => null],
['id' => 6, 'name' => "Politics", 'parent' => 2],
['id' => 3, 'name' => "Rocketry", 'parent' => 1],
['id' => 2, 'name' => "Software", 'parent' => 1],
['id' => 1, 'name' => "Technology", 'parent' => null],
['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],
];
$this->assertSame($exp, Arsse::$db->folderList("john.doe@example.com", null, true)->getAll());
$this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", null, true));
$exp = [
['id' => 6, 'name' => "Politics", 'parent' => 2],
['id' => 3, 'name' => "Rocketry", 'parent' => 1],
['id' => 2, 'name' => "Software", 'parent' => 1],
['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0],
['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0],
['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1],
];
$this->assertSame($exp, Arsse::$db->folderList("john.doe@example.com", 1, true)->getAll());
$this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", 1, true));
$exp = [];
$this->assertSame($exp, Arsse::$db->folderList("jane.doe@example.com", 4, true)->getAll());
$this->assertResult($exp, Arsse::$db->folderList("jane.doe@example.com", 4, true));
Phake::verify(Arsse::$user, Phake::times(2))->authorize("john.doe@example.com", "folderList");
Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "folderList");
}

517
tests/lib/Database/SeriesLabel.php

@ -0,0 +1,517 @@
<?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\Test\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Misc\Context;
use JKingWeb\Arsse\Misc\Date;
use Phake;
trait SeriesLabel {
protected $data = [
'arsse_users' => [
'columns' => [
'id' => 'str',
'password' => 'str',
'name' => 'str',
],
'rows' => [
["jane.doe@example.com", "", "Jane Doe"],
["john.doe@example.com", "", "John Doe"],
["john.doe@example.org", "", "John Doe"],
["john.doe@example.net", "", "John Doe"],
],
],
'arsse_folders' => [
'columns' => [
'id' => "int",
'owner' => "str",
'parent' => "int",
'name' => "str",
],
'rows' => [
[1, "john.doe@example.com", null, "Technology"],
[2, "john.doe@example.com", 1, "Software"],
[3, "john.doe@example.com", 1, "Rocketry"],
[4, "jane.doe@example.com", null, "Politics"],
[5, "john.doe@example.com", null, "Politics"],
[6, "john.doe@example.com", 2, "Politics"],
[7, "john.doe@example.net", null, "Technology"],
[8, "john.doe@example.net", 7, "Software"],
[9, "john.doe@example.net", null, "Politics"],
]
],
'arsse_feeds' => [
'columns' => [
'id' => "int",
'url' => "str",
],
'rows' => [
[1,"http://example.com/1"],
[2,"http://example.com/2"],
[3,"http://example.com/3"],
[4,"http://example.com/4"],
[5,"http://example.com/5"],
[6,"http://example.com/6"],
[7,"http://example.com/7"],
[8,"http://example.com/8"],
[9,"http://example.com/9"],
[10,"http://example.com/10"],
[11,"http://example.com/11"],
[12,"http://example.com/12"],
[13,"http://example.com/13"],
]
],
'arsse_subscriptions' => [
'columns' => [
'id' => "int",
'owner' => "str",
'feed' => "int",
'folder' => "int",
],
'rows' => [
[1,"john.doe@example.com",1,null],
[2,"john.doe@example.com",2,null],
[3,"john.doe@example.com",3,1],
[4,"john.doe@example.com",4,6],
[5,"john.doe@example.com",10,5],
[6,"jane.doe@example.com",1,null],
[7,"jane.doe@example.com",10,null],
[8,"john.doe@example.org",11,null],
[9,"john.doe@example.org",12,null],
[10,"john.doe@example.org",13,null],
[11,"john.doe@example.net",10,null],
[12,"john.doe@example.net",2,9],
[13,"john.doe@example.net",3,8],
[14,"john.doe@example.net",4,7],
]
],
'arsse_articles' => [
'columns' => [
'id' => "int",
'feed' => "int",
'url' => "str",
'title' => "str",
'author' => "str",
'published' => "datetime",
'edited' => "datetime",
'content' => "str",
'guid' => "str",
'url_title_hash' => "str",
'url_content_hash' => "str",
'title_content_hash' => "str",
'modified' => "datetime",
],
'rows' => [
[1,1,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
[2,1,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
[3,2,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
[4,2,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
[5,3,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
[6,3,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
[7,4,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
[8,4,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
[9,5,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
[10,5,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
[11,6,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
[12,6,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
[13,7,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
[14,7,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
[15,8,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
[16,8,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
[17,9,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
[18,9,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
[19,10,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
[20,10,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
[101,11,'http://example.com/1','Article title 1','','2000-01-01 00:00:00','2000-01-01 00:00:01','<p>Article content 1</p>','e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda','f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6','fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4','18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207','2000-01-01 01:00:00'],
[102,11,'http://example.com/2','Article title 2','','2000-01-02 00:00:00','2000-01-02 00:00:02','<p>Article content 2</p>','5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7','0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153','13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9','2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e','2000-01-02 02:00:00'],
[103,12,'http://example.com/3','Article title 3','','2000-01-03 00:00:00','2000-01-03 00:00:03','<p>Article content 3</p>','31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92','f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b','b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406','ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b','2000-01-03 03:00:00'],
[104,12,'http://example.com/4','Article title 4','','2000-01-04 00:00:00','2000-01-04 00:00:04','<p>Article content 4</p>','804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180','f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8','f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3','ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9','2000-01-04 04:00:00'],
[105,13,'http://example.com/5','Article title 5','','2000-01-05 00:00:00','2000-01-05 00:00:05','<p>Article content 5</p>','db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41','d40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022','834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900','43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba','2000-01-05 05:00:00'],
]
],
'arsse_enclosures' => [
'columns' => [
'article' => "int",
'url' => "str",
'type' => "str",
],
'rows' => [
[102,"http://example.com/text","text/plain"],
[103,"http://example.com/video","video/webm"],
[104,"http://example.com/image","image/svg+xml"],
[105,"http://example.com/audio","audio/ogg"],
]
],
'arsse_editions' => [
'columns' => [
'id' => "int",
'article' => "int",
],
'rows' => [
[1,1],
[2,2],
[3,3],
[4,4],
[5,5],
[6,6],
[7,7],
[8,8],
[9,9],
[10,10],
[11,11],
[12,12],
[13,13],
[14,14],
[15,15],
[16,16],
[17,17],
[18,18],
[19,19],
[20,20],
[101,101],
[102,102],
[103,103],
[104,104],
[105,105],
[202,102],
[203,103],
[204,104],
[205,105],
[305,105],
[1001,20],
]
],
'arsse_marks' => [
'columns' => [
'subscription' => "int",
'article' => "int",
'read' => "bool",
'starred' => "bool",
'modified' => "datetime"
],
'rows' => [
[1, 1,1,1,'2000-01-01 00:00:00'],
[5, 19,1,0,'2000-01-01 00:00:00'],
[5, 20,0,1,'2010-01-01 00:00:00'],
[7, 20,1,0,'2010-01-01 00:00:00'],
[8, 102,1,0,'2000-01-02 02:00:00'],
[9, 103,0,1,'2000-01-03 03:00:00'],
[9, 104,1,1,'2000-01-04 04:00:00'],
[10,105,0,0,'2000-01-05 05:00:00'],
[11, 19,0,0,'2017-01-01 00:00:00'],
[11, 20,1,0,'2017-01-01 00:00:00'],
[12, 3,0,1,'2017-01-01 00:00:00'],
[12, 4,1,1,'2017-01-01 00:00:00'],
]
],
'arsse_labels' => [
'columns' => [
'id' => "int",
'owner' => "str",
'name' => "str",
],
'rows' => [
[1,"john.doe@example.com","Interesting"],
[2,"john.doe@example.com","Fascinating"],
[3,"jane.doe@example.com","Boring"],
[4,"john.doe@example.com","Lonely"],
],
],
'arsse_label_members' => [
'columns' => [
'label' => "int",
'article' => "int",
'subscription' => "int",
'assigned' => "bool",
],
'rows' => [
[1, 1,1,1],
[2, 1,1,1],
[1,19,5,1],
[2,20,5,1],
[1, 5,3,0],
[2, 5,3,1],
],
],
];
public function setUpSeries() {
$this->checkLabels = ['arsse_labels' => ["id","owner","name"]];
$this->checkMembers = ['arsse_label_members' => ["label","article","subscription","assigned"]];
$this->user = "john.doe@example.com";
}
public function testAddALabel() {
$user = "john.doe@example.com";
$labelID = $this->nextID("arsse_labels");
$this->assertSame($labelID, Arsse::$db->labelAdd($user, ['name' => "Entertaining"]));
Phake::verify(Arsse::$user)->authorize($user, "labelAdd");
$state = $this->primeExpectations($this->data, $this->checkLabels);
$state['arsse_labels']['rows'][] = [$labelID, $user, "Entertaining"];
$this->compareExpectations($state);
}
public function testAddADuplicateLabel() {
$this->assertException("constraintViolation", "Db", "ExceptionInput");
Arsse::$db->labelAdd("john.doe@example.com", ['name' => "Interesting"]);
}
public function testAddALabelWithAMissingName() {
$this->assertException("missing", "Db", "ExceptionInput");
Arsse::$db->labelAdd("john.doe@example.com", []);
}
public function testAddALabelWithABlankName() {
$this->assertException("missing", "Db", "ExceptionInput");
Arsse::$db->labelAdd("john.doe@example.com", ['name' => ""]);
}
public function testAddALabelWithAWhitespaceName() {
$this->assertException("whitespace", "Db", "ExceptionInput");
Arsse::$db->labelAdd("john.doe@example.com", ['name' => " "]);
}
public function testAddALabelWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->labelAdd("john.doe@example.com", ['name' => "Boring"]);
}
public function testListLabels() {
$exp = [
['id' => 2, 'name' => "Fascinating", 'articles' => 3, 'read' => 1],
['id' => 1, 'name' => "Interesting", 'articles' => 2, 'read' => 2],
['id' => 4, 'name' => "Lonely", 'articles' => 0, 'read' => 0],
];
$this->assertResult($exp, Arsse::$db->labelList("john.doe@example.com"));
$exp = [
['id' => 3, 'name' => "Boring", 'articles' => 0],
];
$this->assertResult($exp, Arsse::$db->labelList("jane.doe@example.com"));
$exp = [];
$this->assertResult($exp, Arsse::$db->labelList("jane.doe@example.com", false));
Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelList");
}
public function testListLabelsWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->labelList("john.doe@example.com");
}
public function testRemoveALabel() {
$this->assertTrue(Arsse::$db->labelRemove("john.doe@example.com", 1));
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);
}
public function testRemoveALabelByName() {
$this->assertTrue(Arsse::$db->labelRemove("john.doe@example.com", "Interesting", true));
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);
}
public function testRemoveAMissingLabel() {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->labelRemove("john.doe@example.com", 2112);
}
public function testRemoveAnInvalidLabel() {
$this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->labelRemove("john.doe@example.com", -1);
}
public function testRemoveAnInvalidLabelByName() {
$this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->labelRemove("john.doe@example.com", [], true);
}
public function testRemoveALabelOfTheWrongOwner() {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->labelRemove("john.doe@example.com", 3); // label ID 3 belongs to Jane
}
public function testRemoveALabelWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->labelRemove("john.doe@example.com", 1);
}
public function testGetThePropertiesOfALabel() {
$exp = [
'id' => 2,
'name' => "Fascinating",
'articles' => 3,
'read' => 1,
];
$this->assertArraySubset($exp, Arsse::$db->labelPropertiesGet("john.doe@example.com", 2));
$this->assertArraySubset($exp, Arsse::$db->labelPropertiesGet("john.doe@example.com", "Fascinating", true));
Phake::verify(Arsse::$user, Phake::times(2))->authorize("john.doe@example.com", "labelPropertiesGet");
}
public function testGetThePropertiesOfAMissingLabel() {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->labelPropertiesGet("john.doe@example.com", 2112);
}
public function testGetThePropertiesOfAnInvalidLabel() {
$this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->labelPropertiesGet("john.doe@example.com", -1);
}
public function testGetThePropertiesOfAnInvalidLabelByName() {
$this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->labelPropertiesGet("john.doe@example.com", [], true);
}
public function testGetThePropertiesOfALabelOfTheWrongOwner() {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->labelPropertiesGet("john.doe@example.com", 3); // label ID 3 belongs to Jane
}
public function testGetThePropertiesOfALabelWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->labelPropertiesGet("john.doe@example.com", 1);
}
public function testMakeNoChangesToALabel() {
$this->assertFalse(Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, []));
}
public function testRenameALabel() {
$this->assertTrue(Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, ['name' => "Curious"]));
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);
}
public function testRenameALabelByName() {
$this->assertTrue(Arsse::$db->labelPropertiesSet("john.doe@example.com", "Interesting", ['name' => "Curious"], true));
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);
}
public function testRenameALabelToTheEmptyString() {
$this->assertException("missing", "Db", "ExceptionInput");
$this->assertTrue(Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, ['name' => ""]));
}
public function testRenameALabelToWhitespaceOnly() {
$this->assertException("whitespace", "Db", "ExceptionInput");
$this->assertTrue(Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, ['name' => " "]));
}
public function testRenameALabelToAnInvalidValue() {
$this->assertException("typeViolation", "Db", "ExceptionInput");
$this->assertTrue(Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, ['name' => []]));
}
public function testCauseALabelCollision() {
$this->assertException("constraintViolation", "Db", "ExceptionInput");
Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, ['name' => "Fascinating"]);
}
public function testSetThePropertiesOfAMissingLabel() {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->labelPropertiesSet("john.doe@example.com", 2112, ['name' => "Exciting"]);
}
public function testSetThePropertiesOfAnInvalidLabel() {
$this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->labelPropertiesSet("john.doe@example.com", -1, ['name' => "Exciting"]);
}
public function testSetThePropertiesOfAnInvalidLabelByName() {
$this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->labelPropertiesSet("john.doe@example.com", [], ['name' => "Exciting"], true);
}
public function testSetThePropertiesOfALabelForTheWrongOwner() {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->labelPropertiesSet("john.doe@example.com", 3, ['name' => "Exciting"]); // label ID 3 belongs to Jane
}
public function testSetThePropertiesOfALabelWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, ['name' => "Exciting"]);
}
public function testListLabelledArticles() {
$exp = [1,19];
$this->assertEquals($exp, Arsse::$db->labelArticlesGet("john.doe@example.com", 1));
$this->assertEquals($exp, Arsse::$db->labelArticlesGet("john.doe@example.com", "Interesting", true));
$exp = [1,5,20];
$this->assertEquals($exp, Arsse::$db->labelArticlesGet("john.doe@example.com", 2));
$this->assertEquals($exp, Arsse::$db->labelArticlesGet("john.doe@example.com", "Fascinating", true));
$exp = [];
$this->assertEquals($exp, Arsse::$db->labelArticlesGet("john.doe@example.com", 4));
$this->assertEquals($exp, Arsse::$db->labelArticlesGet("john.doe@example.com", "Lonely", true));
}
public function testListLabelledArticlesForAMissingLabel() {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->labelArticlesGet("john.doe@example.com", 3);
}
public function testListLabelledArticlesForAnInvalidLabel() {
$this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->labelArticlesGet("john.doe@example.com", -1);
}
public function testListLabelledArticlesWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->labelArticlesGet("john.doe@example.com", 1);
}
public function testApplyALabelToArticles() {
Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([2,5]));
$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);
}
public function testClearALabelFromArticles() {
Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([1,5]), true);
$state = $this->primeExpectations($this->data, $this->checkMembers);
$state['arsse_label_members']['rows'][0][3] = 0;
$this->compareExpectations($state);
}
public function testApplyALabelToArticlesByName() {
Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([2,5]), false, 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);
}
public function testClearALabelFromArticlesByName() {
Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([1,5]), true, true);
$state = $this->primeExpectations($this->data, $this->checkMembers);
$state['arsse_label_members']['rows'][0][3] = 0;
$this->compareExpectations($state);
}
public function testApplyALabelToArticlesWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([2,5]));
}
}

122
tests/lib/Database/SeriesSession.php

@ -0,0 +1,122 @@
<?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\Test\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Misc\Date;
use Phake;
trait SeriesSession {
public function setUpSeries() {
// 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',
'name' => 'str',
],
'rows' => [
["jane.doe@example.com", "", "Jane Doe"],
["john.doe@example.com", "", "John Doe"],
],
],
'arsse_sessions' => [
'columns' => [
'id' => "str",
'user' => "str",
'created' => "datetime",
'expires' => "datetime",
],
'rows' => [
["80fa94c1a11f11e78667001e673b2560", "jane.doe@example.com", $past, $faroff],
["27c6de8da13311e78667001e673b2560", "jane.doe@example.com", $past, $past], // expired
["ab3b3eb8a13311e78667001e673b2560", "jane.doe@example.com", $old, $future], // too old
["da772f8fa13c11e78667001e673b2560", "john.doe@example.com", $past, $future],
],
],
];
}
public function testResumeAValidSession() {
$exp1 = [
'id' => "80fa94c1a11f11e78667001e673b2560",
'user' => "jane.doe@example.com"
];
$exp2 = [
'id' => "da772f8fa13c11e78667001e673b2560",
'user' => "john.doe@example.com"
];
$this->assertArraySubset($exp1, Arsse::$db->sessionResume("80fa94c1a11f11e78667001e673b2560"));
$this->assertArraySubset($exp2, Arsse::$db->sessionResume("da772f8fa13c11e78667001e673b2560"));
$now = time();
// 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);
// session resumption should not check authorization
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertArraySubset($exp1, Arsse::$db->sessionResume("80fa94c1a11f11e78667001e673b2560"));
}
public function testResumeAMissingSession() {
$this->assertException("invalid", "User", "ExceptionSession");
Arsse::$db->sessionResume("thisSessionDoesNotExist");
}
public function testResumeAnExpiredSession() {
$this->assertException("invalid", "User", "ExceptionSession");
Arsse::$db->sessionResume("27c6de8da13311e78667001e673b2560");
}
public function testResumeAStaleSession() {
$this->assertException("invalid", "User", "ExceptionSession");
Arsse::$db->sessionResume("ab3b3eb8a13311e78667001e673b2560");
}
public function testCreateASession() {
$user = "jane.doe@example.com";
$id = Arsse::$db->sessionCreate($user);
$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);
}
public function testCreateASessionWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->sessionCreate("jane.doe@example.com");
}
public function testDestroyASession() {
$user = "jane.doe@example.com";
$id = "80fa94c1a11f11e78667001e673b2560";
$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);
// destroying a session which does not exist is not an error
$this->assertFalse(Arsse::$db->sessionDestroy($user, $id));
}
public function testDestroyASessionForTheWrongUser() {
$user = "john.doe@example.com";
$id = "80fa94c1a11f11e78667001e673b2560";
$this->assertFalse(Arsse::$db->sessionDestroy($user, $id));
}
public function testDestroyASessionWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->sessionDestroy("jane.doe@example.com", "80fa94c1a11f11e78667001e673b2560");
}
}

58
tests/lib/Database/SeriesSubscription.php

@ -48,6 +48,7 @@ trait SeriesSubscription {
'username' => "str",
'password' => "str",
'next_fetch' => "datetime",
'favicon' => "str",
],
'rows' => [] // filled in the series setup
],
@ -108,9 +109,9 @@ trait SeriesSubscription {
public function setUpSeries() {
$this->data['arsse_feeds']['rows'] = [
[1,"http://example.com/feed1", "Ook", "", "",strtotime("now")],
[2,"http://example.com/feed2", "Eek", "", "",strtotime("now - 1 hour")],
[3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour")],
[1,"http://example.com/feed1", "Ook", "", "",strtotime("now"),''],
[2,"http://example.com/feed2", "Eek", "", "",strtotime("now - 1 hour"),'http://example.com/favicon.ico'],
[3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour"),''],
];
// initialize a partial mock of the Database object to later manipulate the feedUpdate method
Arsse::$db = Phake::partialMock(Database::class, $this->drv);
@ -261,6 +262,21 @@ trait SeriesSubscription {
}
public function testListSubscriptionsInAFolder() {
$exp = [
[
'url' => "http://example.com/feed2",
'title' => "Eek",
'folder' => null,
'top_folder' => null,
'unread' => 4,
'pinned' => 1,
'order_type' => 2,
],
];
$this->assertResult($exp, Arsse::$db->subscriptionList($this->user, null, false));
}
public function testListSubscriptionsWithoutRecursion() {
$exp = [
[
'url' => "http://example.com/feed3",
@ -273,6 +289,7 @@ trait SeriesSubscription {
],
];
$this->assertResult($exp, Arsse::$db->subscriptionList($this->user, 2));
}
public function testListSubscriptionsInAMissingFolder() {
@ -286,6 +303,22 @@ trait SeriesSubscription {
Arsse::$db->subscriptionList($this->user);
}
public function testCountSubscriptions() {
$this->assertSame(2, Arsse::$db->subscriptionCount($this->user));
$this->assertSame(1, Arsse::$db->subscriptionCount($this->user, 2));
}
public function testCountSubscriptionsInAMissingFolder() {
$this->assertException("idMissing", "Db", "ExceptionInput");
Arsse::$db->subscriptionCount($this->user, 4);
}
public function testCountSubscriptionsWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->subscriptionCount($this->user);
}
public function testGetThePropertiesOfAMissingSubscription() {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->subscriptionPropertiesGet($this->user, 2112);
@ -321,6 +354,9 @@ trait SeriesSubscription {
]);
$state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0];
$this->compareExpectations($state);
// making no changes is a valid result
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['unhinged' => true]);
$this->compareExpectations($state);
}
public function testMoveASubscriptionToAMissingFolder() {
@ -371,4 +407,20 @@ trait SeriesSubscription {
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['folder' => null]);
}
public function testRetrieveTheFaviconOfASubscription() {
$exp = "http://example.com/favicon.ico";
$this->assertSame($exp, Arsse::$db->subscriptionFavicon(1));
$this->assertSame($exp, Arsse::$db->subscriptionFavicon(2));
$this->assertSame('', Arsse::$db->subscriptionFavicon(3));
$this->assertSame('', Arsse::$db->subscriptionFavicon(4));
// authorization shouldn't have any bearing on this function
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertSame($exp, Arsse::$db->subscriptionFavicon(1));
$this->assertSame($exp, Arsse::$db->subscriptionFavicon(2));
$this->assertSame('', Arsse::$db->subscriptionFavicon(3));
$this->assertSame('', Arsse::$db->subscriptionFavicon(4));
// invalid IDs should simply return an empty string
$this->assertSame('', Arsse::$db->subscriptionFavicon(-2112));
}
}

3
tests/lib/Database/SeriesUser.php

@ -213,6 +213,9 @@ trait SeriesUser {
$state = $this->primeExpectations($this->data, ['arsse_users' => ['id','password','name','rights']]);
$state['arsse_users']['rows'][0][2] = "James Kirk";
$this->compareExpectations($state);
// making now changes should make no changes :)
Arsse::$db->userPropertiesSet("admin@example.net", ['lifeform' => "tribble"]);
$this->compareExpectations($state);
}
public function testSetThePropertiesOfAMissingUser() {

25
tests/lib/Result.php

@ -17,21 +17,24 @@ class Result implements \JKingWeb\Arsse\Db\Result {
// actual public methods
public function getValue() {
$arr = $this->next();
if ($this->valid()) {
$keys = array_keys($arr);
return $arr[array_shift($keys)];
$keys = array_keys($this->current());
$out = $this->current()[array_shift($keys)];
$this->next();
return $out;
}
$this->next();
return null;
}
public function getRow() {
$arr = $this->next();
return ($this->valid() ? $arr : null);
$out = ($this->valid() ? $this->current() : null);
$this->next();
return $out;
}
public function getAll(): array {
return $this->set;
return iterator_to_array($this, false);
}
public function changes() {
@ -56,22 +59,22 @@ class Result implements \JKingWeb\Arsse\Db\Result {
// PHP iterator methods
public function valid() {
return !is_null(key($this->set));
return $this->pos < sizeof($this->set);
}
public function next() {
return next($this->set);
$this->pos++;
}
public function current() {
return current($this->set);
return $this->set[$this->key()];
}
public function key() {
return key($this->set);
return array_keys($this->set)[$this->pos];
}
public function rewind() {
reset($this->set);
$this->pos = 0;
}
}

24
tests/phpunit.xml

@ -18,6 +18,14 @@
<logging>
<log type="coverage-html" target="coverage" showUncoveredFiles="true"/>
</logging>
<listeners>
<listener class="JohnKary\PHPUnit\Listener\SpeedTrapListener">
<arguments><array>
<element key="slowThreshold"><integer>1500</integer></element>
<element key="reportLength"><integer>1000</integer></element>
</array></arguments>
</listener>
</listeners>
<testsuites>
<testsuite name="Exceptions">
@ -47,6 +55,8 @@
</testsuite>
<testsuite name="Database drivers">
<file>Db/TestTransaction.php</file>
<file>Db/TestResultAggregate.php</file>
<file>Db/TestResultEmpty.php</file>
<file>Db/SQLite3/TestDbResultSQLite3.php</file>
<file>Db/SQLite3/TestDbStatementSQLite3.php</file>
<file>Db/SQLite3/TestDbDriverCreationSQLite3.php</file>
@ -57,15 +67,23 @@
<file>Db/SQLite3/Database/TestDatabaseMiscellanySQLite3.php</file>
<file>Db/SQLite3/Database/TestDatabaseMetaSQLite3.php</file>
<file>Db/SQLite3/Database/TestDatabaseUserSQLite3.php</file>
<file>Db/SQLite3/Database/TestDatabaseSessionSQLite3.php</file>
<file>Db/SQLite3/Database/TestDatabaseFolderSQLite3.php</file>
<file>Db/SQLite3/Database/TestDatabaseFeedSQLite3.php</file>
<file>Db/SQLite3/Database/TestDatabaseSubscriptionSQLite3.php</file>
<file>Db/SQLite3/Database/TestDatabaseArticleSQLite3.php</file>
<file>Db/SQLite3/Database/TestDatabaseLabelSQLite3.php</file>
<file>Db/SQLite3/Database/TestDatabaseCleanupSQLite3.php</file>
</testsuite>
<testsuite name="NextCloud News API">
<file>REST/NextCloudNews/TestNCNVersionDiscovery.php</file>
<file>REST/NextCloudNews/TestNCNV1_2.php</file>
<testsuite name="Controllers">
<testsuite name="NCNv1">
<file>REST/NextCloudNews/TestNCNVersionDiscovery.php</file>
<file>REST/NextCloudNews/TestNCNV1_2.php</file>
</testsuite>
<testsuite name="TTRSS">
<file>REST/TinyTinyRSS/TestTinyTinyAPI.php</file>
<file>REST/TinyTinyRSS/TestTinyTinyIcon.php</file>
</testsuite>
</testsuite>
<testsuite name="Refresh service">
<file>Service/TestService.php</file>

21
www/tt-rss/images/README

@ -0,0 +1,21 @@
Silk icon set v1.3
Copyright 2006, Mark James
http://www.famfamfam.com/lab/icons/silk/
Used under license:
http://creativecommons.org/licenses/by/2.5/
A minimal subset of the Silk icon set used by Tiny Tiny RSS is included here
to provide consistent results with certain API functions.
Note that TT-RSS renames some of the icons, and we use the modified names,
again for consistency. Below is a table listing the source file names:
Modified Original
----------- --------------
archive.png box.png
feed.png feed.png
folder.png folder.png
fresh.png cup.png
label.png tag_yellow.png
time.png time.png

BIN
www/tt-rss/images/archive.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 B

BIN
www/tt-rss/images/feed.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

BIN
www/tt-rss/images/folder.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

BIN
www/tt-rss/images/fresh.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

BIN
www/tt-rss/images/label.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

BIN
www/tt-rss/images/time.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 B

Loading…
Cancel
Save