mirror of
https://code.mensbeam.com/MensBeam/Arsse.git
synced 2024-12-22 13:12:41 +00:00
Merge remote-tracking branch 'remotes/origin/ttrss'
This commit is contained in:
commit
40944a9b58
48 changed files with 5809 additions and 466 deletions
|
@ -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"/>
|
||||
|
|
|
@ -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
generated
274
composer.lock
generated
|
@ -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
vendored
17
dist/nginx.conf
vendored
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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 */
|
||||
|
|
800
lib/Database.php
800
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,13 +537,34 @@ 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) {
|
||||
} 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());
|
||||
return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
|
||||
}
|
||||
|
||||
public function subscriptionRemove(string $user, $id): bool {
|
||||
|
@ -480,7 +572,7 @@ 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"]);
|
||||
}
|
||||
$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;
|
||||
}
|
||||
|
||||
public function articleMark(string $user, array $data, Context $context = null): bool {
|
||||
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 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;
|
||||
}
|
||||
// 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;
|
||||
$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);
|
||||
}
|
||||
} 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 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());
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
$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']);
|
||||
// make sure the edition exists
|
||||
$edition = $this->articleValidateEdition($user, $context->edition);
|
||||
// if the edition is not the latest, do not mark the read flag
|
||||
if (!$edition['current']) {
|
||||
$values[0] = null;
|
||||
}
|
||||
} elseif ($context->article()) {
|
||||
// if an article is specified, filter for it (it has already been validated above)
|
||||
$q->setWhere("arsse_articles.id is ?", "int", $context->article);
|
||||
// otherwise if an article context is specified, make sure it's valid
|
||||
$this->articleValidateId($user, $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
|
||||
}
|
||||
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");
|
||||
// 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();
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
if ($context->notModifiedSince()) {
|
||||
$q->setWhere("modified_date <= ?", "datetime", $context->notModifiedSince);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
// commit the transaction
|
||||
$tr->commit();
|
||||
return (bool) $out;
|
||||
}
|
||||
|
||||
public function articleStarredCount(string $user): int {
|
||||
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(*) from arsse_marks where starred is 1 and subscription in (select id from arsse_subscriptions where owner is ?)", "str")->run($user)->getValue();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
public function articleCategoriesGet(string $user, $id): 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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
Normal file
50
lib/Db/ResultAggregate.php
Normal file
|
@ -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
Normal file
25
lib/Db/ResultEmpty.php
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
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() {
|
||||
|
|
|
@ -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
Normal file
1469
lib/REST/TinyTinyRSS/API.php
Normal file
File diff suppressed because it is too large
Load diff
21
lib/REST/TinyTinyRSS/Exception.php
Normal file
21
lib/REST/TinyTinyRSS/Exception.php
Normal file
|
@ -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
Normal file
36
lib/REST/TinyTinyRSS/Icon.php
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
Normal file
10
lib/User/ExceptionSession.php
Normal file
|
@ -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 {
|
||||
}
|
|
@ -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
Normal file
48
sql/SQLite3/1.sql
Normal file
|
@ -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
Normal file
10
tests/Db/SQLite3/Database/TestDatabaseLabelSQLite3.php
Normal file
|
@ -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
Normal file
10
tests/Db/SQLite3/Database/TestDatabaseSessionSQLite3.php
Normal file
|
@ -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
Normal file
101
tests/Db/TestResultAggregate.php
Normal file
|
@ -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
Normal file
37
tests/Db/TestResultEmpty.php
Normal file
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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()) {
|
||||
|
|
|
@ -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), ],
|
||||
|
|
|
@ -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
Normal file
1784
tests/REST/TinyTinyRSS/TestTinyTinyAPI.php
Normal file
File diff suppressed because it is too large
Load diff
52
tests/REST/TinyTinyRSS/TestTinyTinyIcon.php
Normal file
52
tests/REST/TinyTinyRSS/TestTinyTinyIcon.php
Normal file
|
@ -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")));
|
||||
}
|
||||
}
|
|
@ -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 testCountStarredArticlesWithoutAuthority() {
|
||||
public function testCountArticlesWithoutAuthority() {
|
||||
Phake::when(Arsse::$user)->authorize->thenReturn(false);
|
||||
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
|
||||
Arsse::$db->articleStarredCount($this->user);
|
||||
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 testFetchStarredCountsWithoutAuthority() {
|
||||
Phake::when(Arsse::$user)->authorize->thenReturn(false);
|
||||
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
Normal file
517
tests/lib/Database/SeriesLabel.php
Normal file
|
@ -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
Normal file
122
tests/lib/Database/SeriesSession.php
Normal file
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
Normal file
21
www/tt-rss/images/README
Normal file
|
@ -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
Normal file
BIN
www/tt-rss/images/archive.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 555 B |
BIN
www/tt-rss/images/feed.png
Normal file
BIN
www/tt-rss/images/feed.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 691 B |
BIN
www/tt-rss/images/folder.png
Normal file
BIN
www/tt-rss/images/folder.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 537 B |
BIN
www/tt-rss/images/fresh.png
Normal file
BIN
www/tt-rss/images/fresh.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 633 B |
BIN
www/tt-rss/images/label.png
Normal file
BIN
www/tt-rss/images/label.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 586 B |
BIN
www/tt-rss/images/time.png
Normal file
BIN
www/tt-rss/images/time.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 793 B |
Loading…
Reference in a new issue